init: initial commit

This commit is contained in:
2026-10-07 09:28:43 +08:00
commit a68a4ef411
251 changed files with 38279 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/past-go.iml" filepath="$PROJECT_DIR$/.idea/past-go.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
<div id="couple-journal" class="viz-dotted-background">
<div class="preview-controls" aria-label="设计稿状态">
<label>展示状态 <select id="journal-state"><option value="filled">有记录 · 已绑定</option><option value="empty">无记录 · 已绑定</option><option value="unbound">未绑定</option><option value="denied">对方关闭姨姨可见</option></select></label>
<span>设计示例</span>
</div>
<div class="journal-phone" aria-label="精致情侣手账设计稿">
<div class="phone-status"><span>09:41</span><span>••• ▰</span></div>
<header class="phone-nav"><button id="journal-back" aria-label="返回首页" hidden>‹</button><strong id="journal-title">我们</strong><span class="capsule">••• <b>◉</b></span></header>
<main id="journal-content"></main>
<nav class="phone-tabs" aria-label="小程序导航"><button data-page="home"><i data-lucide="house"></i><span>我们</span></button><button data-page="records"><i data-lucide="notebook-pen"></i><span>记录</span></button><button data-page="profile"><i data-lucide="user-round"></i><span>我的</span></button></nav>
<div class="home-indicator"></div>
<div id="journal-sheet" class="sheet-mask" hidden><section class="journal-sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title"><div class="sheet-grip"></div><header><h3 id="sheet-title"></h3><button id="sheet-close" aria-label="关闭">×</button></header><div id="sheet-content"></div></section></div>
</div>
</div>
<style>
#couple-journal {--j-bg:#faf5ee;--j-paper:#fffdf8;--j-ink:#352e2a;--j-muted:#85776d;--j-rose:#b74768;--j-soft:#f4e2e3;--j-line:#e9ddd0;--j-white:#fff;--j-green:#658776;--j-shadow:0 8px 25px #72524012;color:var(--j-ink);font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;padding:20px 10px 28px;color-scheme:light}
#couple-journal *{box-sizing:border-box}#couple-journal button,#couple-journal select{font:inherit}#couple-journal button{cursor:inherit}#couple-journal button:focus-visible,#couple-journal select:focus-visible{outline:3px solid var(--j-rose);outline-offset:3px}#couple-journal [hidden]{display:none!important}
#couple-journal .preview-controls{max-width:390px;margin:0 auto 20px;display:flex;justify-content:space-between;gap:10px;align-items:center;font-size:12px;color:var(--j-muted)}#couple-journal select{border:1px solid var(--j-line);background:var(--j-paper);color:var(--j-ink);border-radius:9px;padding:9px;max-width:230px;font-size:12px}#couple-journal .preview-controls label{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
#couple-journal .journal-phone{max-width:390px;margin:auto;background:var(--j-bg);border:1px solid var(--j-line);border-radius:32px;overflow:hidden;position:relative;box-shadow:0 20px 60px #46332318;min-height:830px}#couple-journal .phone-status{padding:19px 25px 8px;display:flex;justify-content:space-between;font-size:12px;font-weight:600}#couple-journal .phone-nav{height:54px;display:flex;align-items:center;justify-content:center;position:relative}#couple-journal .phone-nav strong{font-size:18px;letter-spacing:2px}#couple-journal .capsule{position:absolute;right:16px;font-size:18px;letter-spacing:1px;border:1px solid var(--j-line);border-radius:24px;padding:4px 10px}#couple-journal .capsule b{font-weight:400;border-left:1px solid var(--j-line);margin-left:7px;padding-left:8px}#couple-journal #journal-back{position:absolute;left:18px;background:none;border:0;font-size:29px;width:40px;height:44px;color:var(--j-ink)}
#couple-journal main{padding:20px 22px 28px;min-height:655px}#couple-journal .eyebrow{font-size:11px;letter-spacing:2px;color:var(--j-muted);text-transform:uppercase}#couple-journal .head-row{display:flex;justify-content:space-between;align-items:center;gap:12px}#couple-journal h2{font-size:21px;margin:6px 0 20px;font-weight:600;letter-spacing:1px}#couple-journal h3{font-size:16px;margin:0;font-weight:600}#couple-journal p{font-size:13px;line-height:1.8;margin:8px 0;color:var(--j-muted)}#couple-journal .quiet-link{background:none;border:0;color:var(--j-rose);font-size:12px;padding:8px 0;min-height:44px}#couple-journal .rose-button{border:0;background:var(--j-rose);color:var(--j-white);border-radius:14px;padding:12px 18px;font-size:13px;min-height:44px}#couple-journal .outline-button{background:var(--j-paper);color:var(--j-rose);border:1px solid var(--j-line);border-radius:12px;padding:11px 15px;min-height:44px;font-size:13px}
#couple-journal .space-cover{position:relative;margin:6px 0 27px;padding:25px 24px 20px;border-radius:6px 30px 6px 30px;background:var(--j-paper);box-shadow:var(--j-shadow);border:1px solid var(--j-line)}#couple-journal .space-cover:before{content:"";position:absolute;left:calc(50% - 28px);top:-9px;width:56px;height:20px;background:#e9d5b7bb;transform:rotate(-5deg);clip-path:polygon(3% 0,100% 3%,97% 96%,0 100%)}#couple-journal .cover-top{display:flex;justify-content:space-between;align-items:center}#couple-journal .little-heart{color:var(--j-rose);transform:rotate(12deg)}#couple-journal .days{font-family:Georgia,serif;font-size:76px;line-height:1.08;letter-spacing:-4px;margin-top:12px;color:var(--j-rose)}#couple-journal .days span{font-family:inherit;font-size:14px;letter-spacing:0;color:var(--j-muted);margin-left:12px}#couple-journal .cover-date{font-size:11px;color:var(--j-muted);letter-spacing:1px;margin-top:9px}#couple-journal .couple-line{display:flex;gap:10px;align-items:center;border-top:1px dashed var(--j-line);padding-top:16px;margin-top:21px;font-size:12px}#couple-journal .avatar{width:33px;height:33px;border-radius:50%;background:var(--j-soft);display:grid;place-items:center;color:var(--j-rose);font-size:13px;border:3px solid var(--j-paper)}#couple-journal .avatar.second{background:#e4ebe3;color:#526b57;margin-left:-15px}#couple-journal .couple-line .distance{margin-left:auto;color:var(--j-muted);font-size:11px}
#couple-journal .memory{display:flex;align-items:center;gap:15px;margin:0 0 27px;padding:0;border:0;text-align:left;background:none;color:var(--j-ink);width:100%;min-height:60px}#couple-journal .calendar-stamp{width:46px;flex-shrink:0;border:1px solid var(--j-line);background:var(--j-paper);text-align:center;border-radius:7px;transform:rotate(-5deg)}#couple-journal .calendar-stamp small{display:block;font-size:9px;padding:4px;background:var(--j-soft);color:var(--j-rose)}#couple-journal .calendar-stamp b{display:block;font-family:Georgia,serif;font-size:24px;padding:4px;font-weight:400}#couple-journal .memory b{font-size:14px;font-weight:500}#couple-journal .memory p{margin:3px 0;font-size:11px}#couple-journal .memory .countdown{margin-left:auto;font-family:Georgia,serif;font-size:29px;color:var(--j-rose)}#couple-journal .countdown small{font-family:sans-serif;font-size:11px;margin-left:4px}
#couple-journal .shortcuts{display:flex;justify-content:space-between;margin-bottom:30px;border-top:1px solid var(--j-line);border-bottom:1px solid var(--j-line);padding:15px 0}#couple-journal .shortcuts button{background:none;border:0;color:var(--j-ink);font-size:11px;display:flex;align-items:center;gap:7px;min-height:44px;padding:0 3px}#couple-journal .shortcuts svg{width:17px;height:17px;color:var(--j-rose)}#couple-journal .section-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}#couple-journal .paper-story{position:relative;background:var(--j-paper);padding:16px;box-shadow:var(--j-shadow);border:1px solid var(--j-line);border-radius:5px;transform:rotate(-1deg);margin-bottom:21px;text-align:left;width:100%;color:var(--j-ink)}#couple-journal .paper-story time{font-family:Georgia,serif;font-size:12px;letter-spacing:1px;color:var(--j-muted)}#couple-journal .paper-story h3{margin:9px 0 8px}#couple-journal .paper-story p{font-size:12px;margin:6px 0 11px}#couple-journal .story-footer{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--j-muted);padding-top:8px}
#couple-journal .landscape{height:147px;overflow:hidden;position:relative;background:linear-gradient(#e2bfac 0%,#f4d8ba 48%,#92aaa9 49%,#c4d1c7 100%);border-radius:2px;margin:8px 0}#couple-journal .landscape:before{content:"";position:absolute;width:180px;height:170px;border-radius:45% 60% 0 0;background:#788e81;left:-30px;top:65px;transform:rotate(30deg)}#couple-journal .landscape:after{content:"";position:absolute;width:200px;height:140px;border-radius:70% 60% 0 0;background:#556f68;right:-40px;top:85px;transform:rotate(-20deg)}#couple-journal .sun{position:absolute;width:29px;height:29px;border-radius:50%;background:#fff1d8;right:72px;top:27px;box-shadow:0 0 32px #fff1d899}#couple-journal .photo-label{position:absolute;bottom:10px;left:12px;color:#fffdf8;z-index:1;font-size:10px;letter-spacing:2px}
#couple-journal .empty-note{text-align:center;padding:35px 15px;background:var(--j-paper);border:1px dashed var(--j-line);border-radius:6px 25px 6px 25px;margin-top:18px}#couple-journal .empty-note>svg{color:var(--j-rose);width:30px;height:30px;margin-bottom:15px}#couple-journal .empty-note h3{font-size:17px}#couple-journal .empty-note p{font-size:12px;margin:10px 0 20px}#couple-journal .invite{border:1px dashed var(--j-line);padding:17px;border-radius:16px;margin-bottom:22px;display:flex;align-items:center;justify-content:space-between;gap:12px}#couple-journal .invite h3{font-size:13px}#couple-journal .invite p{font-size:11px;margin:3px 0}
#couple-journal .record-filters{display:flex;gap:20px;border-bottom:1px solid var(--j-line);margin-bottom:24px}#couple-journal .record-filters button{background:none;border:0;color:var(--j-muted);padding:12px 2px;min-height:44px;font-size:13px}#couple-journal .record-filters button.active{border-bottom:2px solid var(--j-rose);color:var(--j-rose);font-weight:600}#couple-journal .month-label{font-family:Georgia,serif;font-size:28px;letter-spacing:-1px;margin:0 0 18px}#couple-journal .month-label small{font-family:sans-serif;font-size:12px;letter-spacing:0;color:var(--j-muted);margin-left:10px}#couple-journal .tools{margin-top:27px;padding-top:20px;border-top:1px solid var(--j-line)}#couple-journal .tool-list{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:15px}#couple-journal .tool-list button{background:var(--j-paper);border:1px solid var(--j-line);border-radius:12px;color:var(--j-muted);font-size:11px;padding:13px 3px;display:flex;align-items:center;gap:7px;flex-direction:column;min-height:60px}#couple-journal .tool-list svg{width:19px;height:19px;color:var(--j-rose)}
#couple-journal .period-summary{margin:3px 0 25px}#couple-journal .period-summary h2{font-size:28px;margin:8px 0;color:var(--j-rose)}#couple-journal .period-summary p{font-size:12px}#couple-journal .calendar-paper{background:var(--j-paper);border:1px solid var(--j-line);border-radius:22px;padding:18px 13px;box-shadow:var(--j-shadow)}#couple-journal .calendar-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}#couple-journal .calendar-heading b{font-size:15px;font-weight:600}#couple-journal .calendar-heading button{background:none;border:0;color:var(--j-muted);width:44px;height:44px;font-size:21px}#couple-journal .calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px 0}#couple-journal .calendar-grid small{text-align:center;font-size:10px;color:var(--j-muted);padding-bottom:8px}#couple-journal .calendar-grid button{background:none;border:0;font-size:12px;min-height:40px;color:var(--j-ink);border-radius:10px;padding:0;position:relative}#couple-journal .calendar-grid button.period-day{background:var(--j-soft);color:var(--j-rose)}#couple-journal .calendar-grid button.predicted{background:#f8eeeb;color:#956571}#couple-journal .calendar-grid button.selected{background:var(--j-rose);color:var(--j-white);box-shadow:0 3px 8px #b7476830}#couple-journal .calendar-grid button.recorded:after{content:"";position:absolute;bottom:4px;left:calc(50% - 2px);width:4px;height:4px;background:currentColor;border-radius:50%}#couple-journal .calendar-legend{display:flex;justify-content:center;gap:17px;margin-top:17px;font-size:10px;color:var(--j-muted)}#couple-journal .calendar-legend span:before{content:"";display:inline-block;width:7px;height:7px;margin-right:5px;background:var(--j-soft);border-radius:2px}#couple-journal .calendar-legend span+span:before{background:#f8eeeb}#couple-journal .day-details{margin-top:26px}#couple-journal .day-detail-row{display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--j-line);padding:14px 0;font-size:12px}#couple-journal .day-detail-row svg{color:var(--j-rose);width:17px;height:17px}#couple-journal .day-detail-row span:last-child{margin-left:auto;color:var(--j-muted)}#couple-journal .permission-row{display:flex;align-items:center;justify-content:space-between;padding:18px 0;font-size:12px;border-top:1px solid var(--j-line);margin-top:24px}#couple-journal .permission-row p{font-size:11px;margin:3px 0}#couple-journal .toggle{border:0;width:42px;height:26px;border-radius:16px;background:var(--j-rose);padding:3px;display:flex;justify-content:flex-end;flex-shrink:0}#couple-journal .toggle.off{background:#b6a99b;justify-content:flex-start}#couple-journal .toggle:before{content:"";width:20px;height:20px;background:var(--j-white);border-radius:50%}
#couple-journal .phone-tabs{display:flex;background:var(--j-paper);border-top:1px solid var(--j-line);padding:12px 22px 5px;justify-content:space-around}#couple-journal .phone-tabs button{background:none;border:0;display:flex;flex-direction:column;align-items:center;gap:5px;color:var(--j-muted);font-size:10px;min-width:66px;min-height:44px}#couple-journal .phone-tabs svg{width:20px;height:20px}#couple-journal .phone-tabs button.active{color:var(--j-rose)}#couple-journal .home-indicator{width:100px;height:4px;border-radius:4px;background:var(--j-ink);margin:11px auto 9px}#couple-journal .sheet-mask{position:absolute;inset:0;background:#352e2a55;z-index:3;display:flex;align-items:flex-end}#couple-journal .journal-sheet{width:100%;background:var(--j-paper);border-radius:25px 25px 0 0;padding:10px 24px 30px;box-shadow:0 -10px 35px #352e2a18}#couple-journal .sheet-grip{width:32px;height:4px;border-radius:3px;background:var(--j-line);margin:0 auto 20px}#couple-journal .journal-sheet header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}#couple-journal .journal-sheet header button{background:none;border:0;color:var(--j-muted);font-size:23px;width:44px;height:44px}#couple-journal .sheet-actions{display:grid;grid-template-columns:1fr 1fr;gap:12px}#couple-journal .sheet-actions button{display:flex;flex-direction:column;align-items:flex-start;gap:10px;background:var(--j-bg);color:var(--j-ink);border:1px solid var(--j-line);border-radius:15px;padding:17px;font-size:13px}#couple-journal .sheet-actions svg{color:var(--j-rose)}#couple-journal .sheet-field{display:block;font-size:12px;color:var(--j-muted);margin:14px 0}#couple-journal .sheet-field input,#couple-journal .sheet-field textarea{display:block;width:100%;margin-top:8px;border:1px solid var(--j-line);background:var(--j-bg);color:var(--j-ink);border-radius:11px;padding:12px;font:inherit;font-size:16px}#couple-journal .sheet-field textarea{min-height:100px;resize:vertical}
@media(max-width:360px){#couple-journal{padding:12px 0}#couple-journal main{padding:18px 17px}#couple-journal .shortcuts button{gap:4px;font-size:10px}#couple-journal .preview-controls{padding:0 5px}#couple-journal .days{font-size:68px}#couple-journal .calendar-grid button{min-height:38px}}
</style>
<script>
(()=>{
const root=document.getElementById('couple-journal'),content=root.querySelector('#journal-content'),picker=root.querySelector('#journal-state'),sheet=root.querySelector('#journal-sheet');
const saved=window.openai?.widgetState?.modelContent||{};
const state={page:['home','records','period','profile'].includes(saved.page)?saved.page:'home',scenario:['filled','empty','unbound','denied'].includes(saved.scenario)?saved.scenario:'filled',filter:'日记',day:5,visible:true,accent:'#b74768',paper:true};
const icon=name=>`<i data-lucide="${name}" aria-hidden="true"></i>`;
const scene=()=>'<div class="landscape" role="img" aria-label="海边日落示意插画"><span class="sun"></span><span class="photo-label">海边的傍晚</span></div>';
const story=()=>`<button class="paper-story" data-action="story"><time>10.04 / SUNDAY</time><h3>把日落装进口袋</h3><p>沿着海边走了很久,回头的时候,刚好看见你在笑。</p>${scene()}<div class="story-footer">${icon('heart')} 小满 · 昨天 18:32</div></button>`;
const empty=(title,desc,action='留下第一篇',key='publish')=>`<div class="empty-note">${icon('notebook-pen')}<h3>${title}</h3><p>${desc}</p><button class="rose-button" data-action="${key}">${action}</button></div>`;
const tools=()=>`<div class="tools"><h3>一起的小事</h3><div class="tool-list">${[['music-2','歌曲','songs'],['circle-check','打卡','checkin'],['flower-2','姨姨','period'],['paw-print','宠物','pet']].map(([i,n,a])=>`<button data-action="${a}">${icon(i)}${n}</button>`).join('')}</div></div>`;
function home(){const unbound=state.scenario==='unbound';return `<div class="eyebrow">OUR LITTLE WORLD</div><h2>${unbound?'给日常留一点位置':'平凡的日子,也值得记住'}</h2><section class="space-cover"><div class="cover-top"><span class="eyebrow">${unbound?'我的生活手账':'我们在一起'}</span><span class="little-heart">${icon('heart')}</span></div><div class="days">${unbound?'124':'365'}<span>天</span></div><div class="cover-date">${unbound?'注册于 2026.06.03':'故事开始于 2025.10.05'}</div><div class="couple-line"><span class="avatar">满</span>${unbound?'': '<span class="avatar second">屿</span>'}<span>${unbound?'小满':'小满 & 阿屿'}</span><span class="distance">${unbound?'等待另一半加入':'把日子慢慢过成故事'}</span></div></section>${unbound?'<div class="invite"><div><h3>邀请另一半,共写这本手账</h3><p>绑定后,一起收藏日常</p></div><button class="outline-button" data-action="bind">邀请</button></div>':'<button class="memory" data-action="memory"><span class="calendar-stamp"><small>OCT</small><b>12</b></span><span><b>下一次值得期待</b><p>第一次旅行纪念日</p></span><span class="countdown">7<small>天后</small></span></button>'}<div class="shortcuts">${[['calendar-heart','记忆日','memory'],['images','相册','albums'],['notebook-pen','写日记','publish'],['flower-2','姨姨','period']].map(([i,n,a])=>`<button data-action="${a}">${icon(i)}${n}</button>`).join('')}</div><div class="section-top"><h3>最近的一页</h3><button class="quiet-link" data-action="records">翻翻手账 →</button></div>${state.scenario==='filled'||state.scenario==='denied'?story():empty('故事,从今天开始','一句心情、一张照片,都可以成为第一页。')}`;}
function records(){let body;if(state.scenario==='empty'||state.scenario==='unbound')body=empty('还没有'+state.filter,'慢慢积攒属于你们的生活片段。',state.filter==='相册'?'创建相册':state.filter==='记忆日'?'添加记忆日':'写日记',state.filter==='相册'?'newAlbum':state.filter==='记忆日'?'newMemory':'publish');else if(state.filter==='日记')body='<div class="month-label">October<small>2026 · 十月</small></div>'+story()+'<button class="paper-story" data-action="story"><time>10.02 / FRIDAY</time><h3>今天你做的饭,满分</h3><p>没有特别的安排。两个人一起买菜、做饭,就已经是很好的周末。</p><div class="story-footer">'+icon('heart')+' 阿屿 · 10月2日</div></button>';else if(state.filter==='相册')body='<button class="paper-story" data-action="album">'+scene()+'<h3>沿着海岸线</h3><p>12 张照片 · 10月4日更新</p></button>';else body='<button class="memory" data-action="memory"><span class="calendar-stamp"><small>OCT</small><b>12</b></span><span><b>第一次旅行</b><p>每一年,都想和你重游一次</p></span><span class="countdown">7<small>天后</small></span></button>';return `<div class="head-row"><div><span class="eyebrow">PAGES OF US</span><h2>我们的生活手账</h2></div><button class="rose-button" data-action="publish">+ 记录</button></div><div class="record-filters">${['日记','相册','记忆日'].map(n=>`<button data-filter="${n}" class="${state.filter===n?'active':''}">${n}</button>`).join('')}</div>${body}${tools()}`;}
function permission(){return `<div class="permission-row"><div>对方可见<p>关闭后,对方无法查看这份生理记录</p></div><button class="toggle ${state.visible?'':'off'}" role="switch" aria-checked="${state.visible}" aria-label="对方可见" data-action="toggle"></button></div>`;}
function period(){if(state.scenario==='denied')return empty('对方暂时关闭了可见','这份生理记录目前不可查看,<br>需要对方重新开启后才能访问。','刷新权限','refresh')+permission();if(state.scenario==='empty'||state.scenario==='unbound')return `<div class="period-summary"><span class="eyebrow">BODY JOURNAL</span><h2>慢慢了解自己的节奏</h2><p>从记录一次生理期开始。</p></div>${empty('还没有生理期记录','填写最近一次生理期,开始记录身体状态。','设置生理期','settings')}${permission()}`;const days=Array.from({length:35},(_,i)=>{const d=i-3;return d<1||d>31?'<span></span>':`<button data-day="${d}" aria-label="10月${d}日${d<=5?' 经期':''}" class="${d<=5?'period-day':''} ${d>=29?'predicted':''} ${d===state.day?'selected':''} ${[2,5].includes(d)?'recorded':''}">${d}</button>`}).join('');return `<div class="period-summary"><div class="head-row"><span class="eyebrow">BODY JOURNAL</span><button class="quiet-link" data-action="settings">周期设置 ${icon('settings-2')}</button></div><h2>生理期第 5 天</h2><p>平均周期 28 天 · 下次预计 10月29日</p></div><section class="calendar-paper"><div class="calendar-heading"><button data-action="calendarHelp" aria-label="上个月">‹</button><b>2026年 10月</b><button data-action="calendarHelp" aria-label="下个月">›</button></div><div class="calendar-grid">${['日','一','二','三','四','五','六'].map(d=>'<small>'+d+'</small>').join('')}${days}</div><div class="calendar-legend"><span>经期</span><span>预测经期</span></div></section><div class="day-details"><div class="section-top"><h3>10月${state.day}日 · 当天记录</h3><button class="quiet-link" data-action="bodyRecord">+ 添加</button></div>${[2,5].includes(state.day)?`<div class="day-detail-row">${icon('flower-2')}<span>经量</span><span>少量</span></div><div class="day-detail-row">${icon('smile')}<span>心情</span><span>平静</span></div><div class="day-detail-row">${icon('notebook-pen')}<span>备注</span><span>今天状态不错</span></div>`:'<p>这一天还没有记录,留意一下身体的感受。</p><button class="outline-button" data-action="bodyRecord">记录当天状态</button>'}</div>${permission()}`;}
function profile(){return `<div class="eyebrow">MY CORNER</div><h2>我的小角落</h2><div class="space-cover"><div class="head-row"><div><h3>小满</h3><p>ID 520087</p></div><span class="avatar">满</span></div><button class="quiet-link" data-action="editProfile">编辑资料 →</button></div><h3>我们的关系</h3>${state.scenario==='unbound'?'<div class="invite"><p>另一半还没有加入</p><button class="outline-button" data-action="bind">绑定情侣</button></div>':'<div class="permission-row"><span>小满 & 阿屿<p>在一起 365 天</p></span><button class="quiet-link" data-action="relationship">关系管理 →</button></div>'}<div class="permission-row"><span>外观与通知</span><button class="quiet-link" data-action="appearance">设置 →</button></div>`;}
function render(){root.style.setProperty('--j-rose',state.accent);root.querySelectorAll('.paper-story,.space-cover').forEach(e=>e.style.transform=state.paper?'':'none');picker.value=state.scenario;content.innerHTML=({home,records,period,profile}[state.page])();root.querySelector('#journal-title').textContent={home:'我们',records:'记录',period:'姨姨',profile:'我的'}[state.page];root.querySelector('#journal-back').hidden=state.page!=='period';root.querySelectorAll('[data-page]').forEach(b=>{b.classList.toggle('active',b.dataset.page===(state.page==='period'?'records':state.page));b.setAttribute('aria-current',b.classList.contains('active')?'page':'false')});if(!state.paper)root.querySelectorAll('.paper-story,.space-cover').forEach(e=>e.style.transform='none');globalThis.lucide?.createIcons();}
function persist(){window.openai?.setWidgetState?.({modelContent:{page:state.page,scenario:state.scenario},privateContent:null})?.catch(()=>{});}
function openSheet(title,body){root.querySelector('#sheet-title').textContent=title;root.querySelector('#sheet-content').innerHTML=body;sheet.hidden=false;globalThis.lucide?.createIcons();root.querySelector('#sheet-close').focus();}
function action(a){if(['period','records'].includes(a)){state.page=a;render();persist();return;}if(a==='toggle'){state.visible=!state.visible;render();return;}if(a==='publish')openSheet('留下一页日常',`<div class="sheet-actions"><button data-action="write">${icon('notebook-pen')}写日记</button><button data-action="newAlbum">${icon('images')}建相册</button><button data-action="newMemory">${icon('calendar-heart')}记忆日</button><button data-action="bodyRecord">${icon('flower-2')}身体记录</button></div>`);else if(a==='write')openSheet('写日记','<label class="sheet-field">标题<input placeholder="给今天起个名字"></label><label class="sheet-field">正文<textarea placeholder="今天有什么想留下的?"></textarea></label><button class="rose-button" data-action="close">完成预览</button>');else if(a==='bodyRecord')openSheet(`10月${state.day}日 · 记录身体状态`,`<div class="sheet-actions">${[['flower-2','经量'],['smile','心情'],['thermometer','体温'],['notebook-pen','备注']].map(([i,n])=>`<button data-action="recordField">${icon(i)}${n}</button>`).join('')}</div>`);else if(a==='settings')openSheet('周期设置','<label class="sheet-field">最近一次开始日期<input type="date" value="2026-10-01"></label><label class="sheet-field">持续天数<input type="number" value="5"></label><label class="sheet-field">周期长度<input type="number" value="28"></label><button class="rose-button" data-action="close">完成预览</button>');else if(a==='story')openSheet('把日落装进口袋',scene()+'<p>沿着海边走了很久,回头的时候,刚好看见你在笑。</p><p>小满 · 2026年10月4日</p>');else if(a==='close'){sheet.hidden=true;}else{const titles={bind:'邀请另一半',memory:'第一次旅行纪念日',albums:'相册里的片段',album:'沿着海岸线',newAlbum:'创建相册',newMemory:'添加记忆日',refresh:'当前权限',relationship:'关系管理',appearance:'外观与通知',editProfile:'编辑资料',recordField:'当天状态',songs:'一起听的歌',checkin:'一起打卡',pet:'宠物养成',calendarHelp:'日历月份'};openSheet(titles[a]||'记录',a==='refresh'?'<p>对方仍关闭了可见。你自己的可见开关不会解除对方设置的限制。</p>':a==='relationship'?'<p>你和阿屿已绑定。</p><button class="outline-button" data-action="close">解绑情侣</button>':a==='bind'?'<label class="sheet-field">对方ID<input placeholder="输入另一半的ID"></label><button class="rose-button" data-action="close">完成预览</button>':'<p>此处展示交互与排版,正式页面继续使用现有接口数据。</p><button class="outline-button" data-action="close">返回</button>');}}
root.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page){state.page=b.dataset.page;render();persist();}else if(b.dataset.filter){state.filter=b.dataset.filter;render();}else if(b.dataset.day){state.day=Number(b.dataset.day);render();}else if(b.dataset.action)action(b.dataset.action);});picker.addEventListener('change',()=>{state.scenario=picker.value;state.visible=true;render();persist();});root.querySelector('#journal-back').addEventListener('click',()=>{state.page='home';render();persist();});root.querySelector('#sheet-close').addEventListener('click',()=>sheet.hidden=true);sheet.addEventListener('click',e=>{if(e.target===sheet)sheet.hidden=true;});root.addEventListener('keydown',e=>{if(e.key==='Escape')sheet.hidden=true;});window.addEventListener('openai:set_globals',e=>{const v=e.detail?.globals?.widgetState?.modelContent;if(v&&['home','records','period','profile'].includes(v.page)){state.page=v.page;if(['filled','empty','unbound','denied'].includes(v.scenario))state.scenario=v.scenario;render();}});render();if(globalThis.Tweak){const tweak=new Tweak({container:root.querySelector('.journal-phone'),onChange:render});tweak.addColorPicker(state,'accent',{label:'重点色',reference:'--j-rose'});tweak.addToggle(state,'paper',{label:'手账纸张倾斜'});}
})();
</script>
+5
View File
@@ -0,0 +1,5 @@
run/
config.yaml
web/admin/node_modules/
# web/admin/dist 会被 embed,构建后需存在;发布时用 make all 生成
*.log
+44
View File
@@ -0,0 +1,44 @@
# 我们past Go 服务构建
BIN := past-server
GOOS ?= linux
GOARCH ?= amd64
VERSION ?= $(shell date +%Y%m%d-%H%M)
.PHONY: all web build build-windows build-local run test diff clean
all: web build
## 构建 React 管理后台(产物 web/admin/dist 会被 embed)
web:
cd web/admin && npm ci --silent && npm run build
## 交叉编译 Linux 二进制(默认 linux/amd64;树莓派等用 GOARCH=arm64)
build:
CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o dist/$(BIN)-$(GOOS)-$(GOARCH) ./cmd/server
@ls -lh dist/
## Windows 部署包:past-server.exe + 配置模板 + 部署脚本 → dist/windows/
build-windows:
rm -rf dist/windows && mkdir -p dist/windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o dist/windows/past-server.exe ./cmd/server
cp config.example.yaml dist/windows/config.yaml
cp deploy/windows/*.ps1 deploy/windows/README.md dist/windows/
cd dist && rm -f past-windows-deploy.zip && zip -qr past-windows-deploy.zip windows
@ls -lh dist/windows/ dist/past-windows-deploy.zip
## 本机构建(开发用)
build-local:
go build -o run/$(BIN) ./cmd/server
run: build-local
./run/$(BIN) -config config.yaml
test:
go vet ./... && go test ./...
## PHP vs Go 响应对比(需先启动 dev 环境,见 ../dev/reset_dbs.sh)
diff:
cd ../dev && for s in scenarios/*.json; do ./reset_dbs.sh >/dev/null; python3 apidiff.py $$s | tail -1; done
clean:
rm -rf dist run/$(BIN) web/admin/dist
+57
View File
@@ -0,0 +1,57 @@
# 我们past后端(Go 版)
`backend/api/index.php` 的 Go 重写:gin + MySQL,管理后台(React)通过 `embed` 打进同一个二进制。
小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。
本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。
## 目录
```
cmd/server/ 入口
internal/config/ config.yaml 读取
internal/store/ MySQL 访问(行类型对齐 PDO)、schema.go = install.php 的移植
internal/httpapi/ gin 路由、Ctx($_GET/$_POST/$input/$_FILES 等价物)、鉴权
internal/handler/ 121 个 action,按模块拆分(user/couple/timeline/album/diary/songs/checkin/period/pet/misc/admin)
internal/upload/ handleUpload 等价物;文件落在 <data_dir>/uploads/
internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session
internal/geo/ 腾讯位置服务
internal/notify/ cron_notify.php 的进程内定时版
web/admin/ React 管理后台(dist/ 被 embed)
```
## 运行
```bash
cp config.example.yaml config.yaml # 填写数据库 / 微信配置
go build -o past-server ./cmd/server
./past-server -config config.yaml
```
运行目录布局(`data_dir`,默认为二进制所在目录):
```
data_dir/
├── uploads/ 用户上传文件(从 PHP 的 backend/uploads 原样迁移)
└── data/ access_token.cache 等
```
URL 映射:
| URL | 说明 |
|---|---|
| `/api/接口名` | 小程序、管理后台、上传统一入口 |
| `/backend/api/index.php?action=…` | 旧客户端兼容入口 |
| `/backend/uploads/…` | 上传文件静态服务(对应 DB 中 `uploads/…` 路径) |
| `/backend/admin/` | 管理后台 SPA |
| `/healthz` | 健康检查 |
## 与 PHP 版的差异(有意为之)
见 `../dev/API_CONTRACT.md` 与对比测试 `../dev/apidiff.py`。已知的 PHP 原有 bug 处理:
- `*_add` 接口返回的 `id` 改为真实记录 id(PHP 返回的是 activity_log 的 id)。
- `pet_dynamics` 分页可用(PHP 因 `LIMIT '20'` 语法错误恒 500)。
- 宠物成就解锁可用(PHP 因 `pet_dynamics.user_id=0` 违反外键恒 500)。
- `logout` 会真正失效 token;登录不再踢掉其它设备的 token。
- 数据库迁移只在启动时执行一次(PHP 每次请求执行)。
+146
View File
@@ -0,0 +1,146 @@
// 我们past后端(Go 版):API 与 PHP 版 backend/api/index.php 完全兼容。
package main
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
_ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用
"past/internal/config"
"past/internal/geo"
"past/internal/handler"
"past/internal/httpapi"
"past/internal/notify"
"past/internal/store"
"past/internal/upload"
"past/internal/wechat"
"past/web"
)
// version 由构建时 -ldflags 注入
var version = "dev"
func main() {
cfgPath := flag.String("config", "config.yaml", "配置文件路径")
showVer := flag.Bool("version", false, "显示版本")
svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop")
flag.Parse()
if *showVer {
fmt.Println("past-server", version)
return
}
if *svcCmd != "" {
if err := serviceControl(*svcCmd, *cfgPath); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
return
}
// 作为 Windows 服务运行时,工作目录是 System32:配置文件相对路径按可执行文件所在目录解析
if !filepath.IsAbs(*cfgPath) {
if exe, err := os.Executable(); err == nil {
if _, err := os.Stat(*cfgPath); err != nil {
*cfgPath = filepath.Join(filepath.Dir(exe), *cfgPath)
}
}
}
if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) {
return
}
// 控制台模式:Ctrl+C / SIGTERM 触发优雅退出
stop := make(chan struct{})
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
close(stop)
}()
serve(*cfgPath, stop)
}
// serve 启动全部组件并阻塞到 stop 关闭
func serve(cfgPath string, stop <-chan struct{}) {
// 与 PHP date_default_timezone_set('Asia/Shanghai') 一致
if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil {
time.Local = loc
}
cfg, err := config.Load(cfgPath)
if err != nil {
slog.Error("配置加载失败", "err", err)
os.Exit(1)
}
// 确保必要目录存在(对应 config.php 中的 $dirs)
for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} {
os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755)
}
// 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径)
logOut := io.Writer(os.Stdout)
if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
logOut = io.MultiWriter(os.Stdout, f)
defer f.Close()
}
slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo})))
db, err := store.Open(cfg.DSN())
if err != nil {
slog.Error("数据库连接失败", "err", err)
os.Exit(1)
}
db.NumericAsString = cfg.PHPCompat < "8.1"
if err := db.InitSchema(); err != nil {
slog.Error("数据库初始化失败", "err", err)
os.Exit(1)
}
wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db)
app := &httpapi.App{
Cfg: cfg,
DB: db,
Wechat: wx,
Geo: geo.New(cfg.TencentMapKey),
Admin: httpapi.NewAdminSessions(),
}
uploader := &upload.Uploader{Dir: cfg.UploadDir(), MaxSize: cfg.MaxUploadMB * 1024 * 1024, Checker: wx}
router := httpapi.NewRouter(app, web.AdminFS())
handler.Register(router, uploader)
slog.Info("已注册 action", "count", len(router.Actions()))
if cfg.NotifyIntervalMin > 0 {
go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute)
}
srv := &http.Server{
Addr: cfg.Listen,
Handler: router.Engine(cfg.Debug),
ReadHeaderTimeout: 30 * time.Second,
ReadTimeout: 180 * time.Second, // 大文件上传
WriteTimeout: 180 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
slog.Info("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("服务异常退出", "err", err)
os.Exit(1)
}
}()
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx)
slog.Info("服务已停止")
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package main
import "fmt"
func runAsServiceIfNeeded(run func(stop <-chan struct{})) bool { return false }
func serviceControl(cmd, cfgPath string) error {
return fmt.Errorf("-service 仅在 Windows 下可用(Linux 请使用 systemd)")
}
+120
View File
@@ -0,0 +1,120 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const svcName = "past-server"
// runAsServiceIfNeeded 在被 Windows 服务管理器启动时接管生命周期;返回 true 表示已作为服务运行完毕。
func runAsServiceIfNeeded(run func(stop <-chan struct{})) bool {
isSvc, err := svc.IsWindowsService()
if err != nil || !isSvc {
return false
}
_ = svc.Run(svcName, &svcHandler{run: run})
return true
}
type svcHandler struct{ run func(stop <-chan struct{}) }
func (h *svcHandler) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) {
s <- svc.Status{State: svc.StartPending}
stop := make(chan struct{})
done := make(chan struct{})
go func() { h.run(stop); close(done) }()
s <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
for {
select {
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
s <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
s <- svc.Status{State: svc.StopPending}
close(stop)
select {
case <-done:
case <-time.After(15 * time.Second):
}
return false, 0
}
case <-done:
return false, 0
}
}
}
// serviceControl 处理 -service install|uninstall|start|stop(需要管理员权限)
func serviceControl(cmd, cfgPath string) error {
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("连接服务管理器失败(请用管理员身份运行): %w", err)
}
defer m.Disconnect()
switch cmd {
case "install":
exe, err := os.Executable()
if err != nil {
return err
}
abs, _ := filepath.Abs(cfgPath)
if s, err := m.OpenService(svcName); err == nil {
s.Close()
return fmt.Errorf("服务 %s 已存在(先执行 -service uninstall)", svcName)
}
s, err := m.CreateService(svcName, exe, mgr.Config{
DisplayName: "我们past Go 服务 (past-server)",
Description: "past.sundynix.cn 后端 API 与管理后台",
StartType: mgr.StartAutomatic,
}, "-config", abs)
if err != nil {
return err
}
defer s.Close()
// 异常退出自动重启
_ = s.SetRecoveryActions([]mgr.RecoveryAction{
{Type: mgr.ServiceRestart, Delay: 3 * time.Second},
{Type: mgr.ServiceRestart, Delay: 3 * time.Second},
{Type: mgr.ServiceRestart, Delay: 10 * time.Second},
}, 86400)
if err := s.Start(); err != nil {
return fmt.Errorf("已安装但启动失败: %w", err)
}
fmt.Println("服务已安装并启动:", svcName, "配置:", abs)
case "uninstall":
s, err := m.OpenService(svcName)
if err != nil {
return fmt.Errorf("服务不存在: %w", err)
}
defer s.Close()
_, _ = s.Control(svc.Stop)
time.Sleep(2 * time.Second)
if err := s.Delete(); err != nil {
return err
}
fmt.Println("服务已卸载:", svcName)
case "start", "stop":
s, err := m.OpenService(svcName)
if err != nil {
return fmt.Errorf("服务不存在: %w", err)
}
defer s.Close()
if cmd == "start" {
return s.Start()
}
_, err = s.Control(svc.Stop)
return err
default:
return fmt.Errorf("未知 -service 命令: %s(install|uninstall|start|stop)", cmd)
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
# 我们past Go 服务配置(复制为 config.yaml 后填写真实值)
listen: ":8080"
# 运行数据目录(uploads/ 与 data/ 在其下),留空则为可执行文件所在目录
data_dir: ""
db:
host: 127.0.0.1
port: 3306
name: past
user: past
pass: your_db_password
wechat:
appid: your_appid
secret: your_secret
notify_template_id: tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8
tencent_map_key: ""
max_upload_mb: 50
# 定时补发通知间隔(分钟),0 关闭
notify_interval_min: 5
+36
View File
@@ -0,0 +1,36 @@
# 20261005 UI 精修与驼峰 API 更新
## 部署顺序
1. 停止现有服务,备份原程序;用新 Windows AMD64 `past-server.exe` 替换程序。保留当前 `config.yaml`、`uploads`、`data` 和数据库。示例配置仅供参考,不要覆盖现有配置。
2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。
3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。
4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。
5. 浏览器访问 `https://past.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。
6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。
7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。
## 接口规则
- 请求地址:`/api/接口名`,例如 `/api/periodList`、`/api/diaryList?page=1&pageSize=20`。
- 小程序、管理后台、上传统一使用新路径;不再附加 GET `_t`。
- 例:`POST /api/periodUpdate`,JSON `{ "id": "314", "cycleLength": 25, "periodLength": 5, "startDate": "2026-09-27", "endDate": "2026-10-01" }`。
- 路径、查询参数、JSON 请求与响应字段统一 lowerCamelCase,例如 `cycleLength`、`periodVisible`、`canAccess`。Multipart 表单字段同样为驼峰。字段值、请求方法和鉴权延续原业务约定。
- 后端及两个客户端在网络边界转换字段名,内部数据库和页面数据模型保持既有结构。旧 PHP 入口与带下划线的旧路径继续兼容。API 响应 `Cache-Control: no-store`;后台请求使用 `cache: no-store`。
- Bearer、上传 token 与后台同源 Cookie 仍有效。上传继续携带兼容 Android 的 token/action 表单项。
- 原 PHP 风格入口继续兼容已发布旧小程序。媒体与后台静态地址仍在 `/backend/` 下。
## 小程序验证
- Android 与 iPhone:标题与微信胶囊不重叠,首屏和底部内容不被遮挡。
- 首页:未绑定显示注册天数和邀请入口,绑定后显示在一起天数;解绑后首页、我的立即按接口更新。
- 三个底部入口:首页、记录、我的;详情返回与发布、编辑流程正常。
- 日记和相册:首次加载、空列表、网络失败分别展示;创建相册与上传照片正常。
- 姨姨:对方可见单独操作;关闭自己的开关不能把自己锁住,权限由 API 判断。双方都有绑定前数据时仍选择唯一记录。
- 改周期、持续天数、开始和结束日期保存后,重新进入展示服务端最新值。
本地已检查 WXML/WXSS 编译、JS 语法、请求/上传 URL 与导航尺寸、Go 测试与管理后台构建;未替代真实微信设备和线上数据库联调。
## 本次 UI 精修
小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。
+28
View File
@@ -0,0 +1,28 @@
# 放在 past.sundynix.cn 现有 HTTPS server 块里。
# proxy_pass 末尾不要加 /,需要保留 /api/接口名。
location = /api {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /api/ {
client_max_body_size 50m;
proxy_read_timeout 120s;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 如果原来没有把 /backend/ 转给 Go,再加入下面这一段。
# 已有同名 location 时修改原配置,不要重复添加。
# location ^~ /backend/ {
# client_max_body_size 50m;
# proxy_read_timeout 120s;
# proxy_pass http://127.0.0.1:8081;
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
+42
View File
@@ -0,0 +1,42 @@
module past
go 1.26.4
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.10.1
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+101
View File
@@ -0,0 +1,101 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+90
View File
@@ -0,0 +1,90 @@
// Package config 读取服务配置(对应 PHP 的 config.php + config.credentials.php)。
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
Listen string `yaml:"listen"` // 监听地址,如 ":8080"
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
// DataDir 运行数据目录:其下 uploads/ 存用户上传文件,data/ 存日志与缓存。
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
DataDir string `yaml:"data_dir"`
DB struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Name string `yaml:"name"`
User string `yaml:"user"`
Pass string `yaml:"pass"`
} `yaml:"db"`
Wechat struct {
AppID string `yaml:"appid"`
Secret string `yaml:"secret"`
// 订阅消息模板 ID(对应 WECHAT_SUBSCRIBE_TEMPLATES['notify'])
NotifyTemplateID string `yaml:"notify_template_id"`
} `yaml:"wechat"`
TencentMapKey string `yaml:"tencent_map_key"`
// PHPCompat 对齐的 PHP 版本:"8.0"(默认,数据库数值按字符串返回)或 "8.1"(原生类型)
PHPCompat string `yaml:"php_compat"`
// MaxUploadMB 单文件上传上限(PHP: MAX_UPLOAD_SIZE = 50MB)
MaxUploadMB int64 `yaml:"max_upload_mb"`
// Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭
NotifyIntervalMin int `yaml:"notify_interval_min"`
}
func Load(path string) (*Config, error) {
c := &Config{}
c.Listen = ":8080"
c.DB.Host = "127.0.0.1"
c.DB.Port = 3306
c.DB.Name = "past"
c.DB.User = "past"
c.MaxUploadMB = 50
c.NotifyIntervalMin = 5
c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8"
c.PHPCompat = "8.0"
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置文件 %s: %w", path, err)
}
if err := yaml.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
}
if c.DataDir == "" {
exe, err := os.Executable()
if err != nil {
return nil, err
}
c.DataDir = filepath.Dir(exe)
}
abs, err := filepath.Abs(c.DataDir)
if err != nil {
return nil, err
}
c.DataDir = abs
return c, nil
}
func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") }
func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "data") }
func (c *Config) DSN() string {
// interpolateParams=true:统一走文本协议,所有列以字节返回,由 store 按列类型转换(对齐 PDO 行为)
// time_zone 会在驱动建立的每个 MySQL 连接上设置会话时区,保证 SQL NOW()/CURRENT_TIMESTAMP
// 与 Go 进程的 Asia/Shanghai 一致。使用固定 +08:00 不依赖 MySQL 时区表是否安装。
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=false&interpolateParams=true&loc=Local&time_zone=%%27%%2B08%%3A00%%27&multiStatements=false",
c.DB.User, c.DB.Pass, c.DB.Host, c.DB.Port, c.DB.Name)
}
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"strings"
"testing"
"github.com/go-sql-driver/mysql"
)
func TestDSNSetsShanghaiOffsetForEveryConnection(t *testing.T) {
c := &Config{}
c.DB.Host = "127.0.0.1"
c.DB.Port = 3306
c.DB.Name = "past"
c.DB.User = "past"
got := c.DSN()
if !strings.Contains(got, "time_zone=%27%2B08%3A00%27") {
t.Fatalf("DSN does not set MySQL session time zone: %s", got)
}
parsed, err := mysql.ParseDSN(got)
if err != nil {
t.Fatalf("invalid DSN: %v", err)
}
if parsed.Params["time_zone"] != "'+08:00'" {
t.Fatalf("unexpected decoded MySQL time_zone: %q", parsed.Params["time_zone"])
}
}
+152
View File
@@ -0,0 +1,152 @@
// Package geo 对应 index.php 顶部的腾讯位置服务函数:逆地理编码、IP 定位、球面距离。
package geo
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
"past/internal/store"
)
type Client struct {
Key string
http *http.Client
}
func New(key string) *Client {
return &Client{Key: key, http: &http.Client{Timeout: 5 * time.Second}}
}
func (c *Client) get(u string) map[string]any {
resp, err := c.http.Get(u)
if err != nil {
return nil
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if len(b) == 0 {
return nil
}
var data map[string]any
if json.Unmarshal(b, &data) != nil {
return nil
}
return data
}
func statusOK(data map[string]any) bool {
if data == nil {
return false
}
st, ok := data["status"]
// PHP 用 === 0 判断:JSON 数字 0 解码为 float 0
return ok && store.ToFloat(st) == 0 && st != nil
}
func sub(m map[string]any, key string) map[string]any {
if m == nil {
return nil
}
v, _ := m[key].(map[string]any)
return v
}
// ReverseGeocode 经纬度(gcj02)→ "省 市 区县";未配置 key 或失败返回空串。
func (c *Client) ReverseGeocode(lat, lng float64) string {
if c.Key == "" || lat == 0 || lng == 0 {
return ""
}
u := fmt.Sprintf("https://apis.map.qq.com/ws/geocoder/v1/?location=%s,%s&key=%s&coord_type=5",
store.ToStr(lat), store.ToStr(lng), url.QueryEscape(c.Key))
data := c.get(u)
if !statusOK(data) {
return ""
}
result := sub(data, "result")
comp := sub(result, "address_component")
if comp == nil {
return ""
}
var parts []string
prov, city, dist := store.ToStr(comp["province"]), store.ToStr(comp["city"]), store.ToStr(comp["district"])
if prov != "" {
parts = append(parts, prov)
}
if city != "" && city != prov {
parts = append(parts, city)
}
if dist != "" {
parts = append(parts, dist)
}
if len(parts) > 0 {
return strings.Join(parts, " ")
}
return store.ToStr(result["address"])
}
// IPGeolocate IP → "市 区县 街道"(不含省份);失败返回空串。
func (c *Client) IPGeolocate(ip string) string {
if c.Key == "" || ip == "" || ip == "127.0.0.1" || ip == "::1" {
return ""
}
data := c.get("https://apis.map.qq.com/ws/location/v1/ip?ip=" + url.QueryEscape(ip) + "&key=" + url.QueryEscape(c.Key))
if !statusOK(data) {
return ""
}
result := sub(data, "result")
ad := sub(result, "ad_info")
if ad == nil {
return ""
}
if loc := sub(result, "location"); loc != nil && store.Truthy(loc["lat"]) && store.Truthy(loc["lng"]) {
geo := c.get(fmt.Sprintf("https://apis.map.qq.com/ws/geocoder/v1/?location=%s,%s&key=%s&coord_type=5",
store.ToStr(loc["lat"]), store.ToStr(loc["lng"]), url.QueryEscape(c.Key)))
if statusOK(geo) {
if comp := sub(sub(geo, "result"), "address_component"); comp != nil {
var parts []string
prov, city, dist, street := store.ToStr(comp["province"]), store.ToStr(comp["city"]), store.ToStr(comp["district"]), store.ToStr(comp["street"])
if city != "" && city != prov {
parts = append(parts, city)
}
if dist != "" {
parts = append(parts, dist)
}
if street != "" {
parts = append(parts, street)
}
if len(parts) > 0 {
return strings.Join(parts, " ")
}
}
}
}
var parts []string
prov, city, dist := store.ToStr(ad["province"]), store.ToStr(ad["city"]), store.ToStr(ad["district"])
if city != "" && city != prov {
parts = append(parts, city)
}
if dist != "" {
parts = append(parts, dist)
}
if len(parts) > 0 {
return strings.Join(parts, " ")
}
return store.ToStr(sub(result, "location")["city"])
}
// Haversine 两点球面距离(公里,保留 1 位小数)
func Haversine(lat1, lng1, lat2, lng2 float64) float64 {
const r = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLng := (lng2 - lng1) * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return math.Round(r*c*10) / 10
}
+816
View File
@@ -0,0 +1,816 @@
package handler
import (
"fmt"
"math"
"regexp"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
func registerAdmin(r *httpapi.Router) {
r.Handle("admin_login", adminLogin)
r.Handle("admin_check_auth", adminCheckAuth)
r.Handle("admin_logout", adminLogout)
r.Handle("admin_stats", adminStats)
r.Handle("admin_stats_detail", adminStatsDetail)
r.Handle("admin_couples", adminCouples)
r.Handle("admin_couple_detail", adminCoupleDetail)
r.Handle("admin_pet_detail", adminPetDetail)
r.Handle("admin_pets", adminPets)
r.Handle("admin_users", adminUsers)
r.Handle("admin_fix_last_login", adminFixLastLogin)
r.Handle("admin_clean_locations", adminCleanLocations)
r.Handle("admin_user_detail", adminUserDetail)
r.Handle("admin_change_password", adminChangePassword)
r.Handle("admin_change_username", adminChangeUsername)
r.Handle("admin_update_user", adminUpdateUser)
r.Handle("admin_delete_user", adminDeleteUser)
r.Handle("admin_unbind_requests", adminUnbindRequests)
r.Handle("admin_force_unbind", adminForceUnbind)
r.Handle("admin_restore_couple", adminRestoreCouple)
r.Handle("admin_delete_couple_data", adminDeleteCoupleData)
r.Handle("admin_delete_content", adminDeleteContent)
r.Handle("admin_contents", adminContents)
r.Handle("admin_reset_user_password", adminResetUserPassword)
r.Handle("admin_activity_log", adminActivityLog)
r.Handle("admin_get_global_bg", adminGetGlobalBg)
r.Handle("admin_set_global_bg", adminSetGlobalBg)
r.Handle("admin_clear_global_bg", adminClearGlobalBg)
r.Handle("admin_couple_bg_list", adminCoupleBgList)
r.Handle("admin_clear_couple_bg", adminClearCoupleBg)
}
func adminLogin(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
admin, err := c.DB.QueryOne("SELECT * FROM admins WHERE username = ?", c.InTrim("username"))
if err != nil {
return err
}
if admin != nil && verifyPassword(c.InStr("password"), admin.Str("password")) {
c.AdminLogin(admin.Int("id"), admin.Str("username"))
return c.OK(M{"success": true, "username": admin["username"]})
}
return c.Err(401, "用户名或密码错误")
}
func adminCheckAuth(c *httpapi.Ctx) error {
return c.OK(M{"logged_in": c.AdminSession() != nil})
}
func adminLogout(c *httpapi.Ctx) error {
c.AdminLogout()
return c.OK(M{"success": true})
}
// tableCount 对应 $db->query("SELECT COUNT(*) as c FROM t")->fetch()['c'](原样,未 intval)
func tableCount(c *httpapi.Ctx, table string) any {
return countRaw(c, "c", "SELECT COUNT(*) as c FROM "+table)
}
func adminStats(c *httpapi.Ctx) error {
return c.OK(M{
"total_users": tableCount(c, "users"), "total_couples": tableCount(c, "couples"),
"total_timelines": tableCount(c, "timeline"), "total_diaries": tableCount(c, "diaries"),
"total_albums": tableCount(c, "albums"), "total_photos": tableCount(c, "photos"),
"total_songs": tableCount(c, "songs"), "total_checkins": tableCount(c, "checkins"),
"total_pets": tableCount(c, "pets"), "total_pet_achievements": tableCount(c, "pet_achievements"),
})
}
var ymdRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
func adminStatsDetail(c *httpapi.Ctx) error {
date := c.Q("date")
if !c.QHas("date") {
date = today()
}
if !ymdRe.MatchString(date) {
return c.Err(400, "日期格式错误,请使用 YYYY-MM-DD")
}
month := date[:7]
dateCount := func(table string) int64 {
return countC(c, "SELECT COUNT(*) as c FROM `"+table+"` WHERE DATE(created_at) = ?", date)
}
contents := M{}
var total int64
for _, t := range []string{"timeline", "diaries", "albums", "photos", "songs", "checkins", "checkin_logs"} {
n := dateCount(t)
contents[t] = n
total += n
}
return c.OK(M{
"date": date,
"today_registers": dateCount("users"),
"month_registers": countC(c, "SELECT COUNT(*) as c FROM users WHERE DATE_FORMAT(created_at, '%Y-%m') = ?", month),
"date_registers": dateCount("users"),
"date_couples": dateCount("couples"),
"date_contents": contents,
"date_contents_total": total,
"total_users": tableCount(c, "users"),
"total_couples": tableCount(c, "couples"),
})
}
// paged 后台列表分页包装:query 参数 page / page_size(默认 10,最大 100),返回 {items,total,page,page_size,total_pages}
func paged(c *httpapi.Ctx, all []store.Row) M {
page, pageSize, offset := pageParams(c, 10, 100)
total := int64(len(all))
end := offset + pageSize
if offset > total {
offset = total
}
if end > total {
end = total
}
return M{
"items": rows(all[offset:end]), "total": total, "page": page, "page_size": pageSize,
"total_pages": int64(math.Ceil(float64(total) / float64(pageSize))),
}
}
func adminCouples(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT c.*, c.couple_space_id, u1.nickname as user1_nick, u1.username as user1_name, u1.gender as user1_gender, u1.age as user1_age, u1.birthday as user1_birthday, u2.nickname as user2_nick, u2.username as user2_name, u2.gender as user2_gender, u2.age as user2_age, u2.birthday as user2_birthday, (SELECT COUNT(*) FROM timeline WHERE couple_id = c.id) as timeline_count, (SELECT COUNT(*) FROM diaries WHERE couple_id = c.id) as diary_count, (SELECT COUNT(*) FROM albums WHERE couple_id = c.id) as album_count, (SELECT COUNT(*) FROM songs WHERE couple_id = c.id) as songs_count, (SELECT COUNT(*) FROM checkins WHERE couple_id = c.id) as checkin_count FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.created_at DESC")
if err != nil {
return err
}
return c.OK(paged(c, rs))
}
// petFullDetail 宠物详情各子表(admin_couple_detail / admin_pet_detail 共用)
func petFullDetail(c *httpapi.Ctx, cid int64) (dailies, achievements, dynamics, items []store.Row, err error) {
if dailies, err = c.DB.Query("SELECT * FROM pet_dailies WHERE couple_id = ? ORDER BY date DESC LIMIT 60", cid); err != nil {
return
}
if achievements, err = c.DB.Query("SELECT pa.* FROM pet_achievements pa WHERE pa.couple_id = ? ORDER BY pa.unlocked_at DESC", cid); err != nil {
return
}
if dynamics, err = c.DB.Query("SELECT * FROM pet_dynamics WHERE couple_id = ? ORDER BY created_at DESC LIMIT 30", cid); err != nil {
return
}
items, err = c.DB.Query("SELECT * FROM pet_items WHERE couple_id = ? ORDER BY created_at DESC", cid)
return
}
// rowOrFalse 对应 PDO fetch() 无结果返回 false
func rowOrFalse(r store.Row) any {
if r == nil {
return false
}
return r
}
func adminCoupleDetail(c *httpapi.Ctx) error {
var cid int64
if c.Method == "GET" {
cid = c.QInt("couple_id")
} else {
cid = c.InInt("couple_id")
}
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
data := M{}
users, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar FROM users WHERE couple_id = ?", cid)
if err != nil {
return err
}
data["users"] = rows(users)
data["user1_id"], data["user2_id"] = int64(0), int64(0)
if len(users) > 0 {
data["user1_id"] = users[0]["id"]
}
if len(users) > 1 {
data["user2_id"] = users[1]["id"]
}
q := func(key, sql string) error {
rs, err := c.DB.Query(sql, cid)
if err != nil {
return err
}
data[key] = rows(rs)
return nil
}
if err := q("timeline", "SELECT * FROM timeline WHERE couple_id = ? ORDER BY event_date DESC"); err != nil {
return err
}
if err := q("diaries", "SELECT * FROM diaries WHERE couple_id = ? ORDER BY diary_date DESC"); err != nil {
return err
}
if err := q("albums", "SELECT * FROM albums WHERE couple_id = ?"); err != nil {
return err
}
data["photos"] = []store.Row{}
if albums := data["albums"].([]store.Row); len(albums) > 0 {
ids := make([]any, 0, len(albums))
for _, a := range albums {
ids = append(ids, a["id"])
}
photos, err := c.DB.Query("SELECT * FROM photos WHERE album_id IN ("+placeholders(len(ids))+") ORDER BY album_id, sort_order ASC, created_at DESC", ids...)
if err != nil {
return err
}
data["photos"] = rows(photos)
}
if err := q("songs", "SELECT * FROM songs WHERE couple_id = ? ORDER BY created_at DESC"); err != nil {
return err
}
if err := q("checkins", "SELECT * FROM checkins WHERE couple_id = ? ORDER BY created_at DESC"); err != nil {
return err
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid)
if err != nil {
return err
}
if pet != nil {
pet["achievement_count"] = countC(c, "SELECT COUNT(*) as c FROM pet_achievements WHERE couple_id = ?", cid)
pet["daily_count"] = countC(c, "SELECT COUNT(*) as c FROM pet_dailies WHERE couple_id = ?", cid)
dailies, achs, dyns, items, err := petFullDetail(c, cid)
if err != nil {
return err
}
pet["dailies"], pet["achievements"], pet["dynamics"], pet["items"] = rows(dailies), rows(achs), rows(dyns), rows(items)
}
data["pet"] = rowOrFalse(pet)
return c.OK(data)
}
func adminPetDetail(c *httpapi.Ctx) error {
cid := c.QInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", cid)
if err != nil {
return c.Err(500, "服务器错误: "+err.Error())
}
dailies, achs, dyns, items, err := petFullDetail(c, cid)
if err != nil {
return c.Err(500, "服务器错误: "+err.Error())
}
return c.OK(M{"pet": rowOrFalse(pet), "dailies": rows(dailies), "achievements": rows(achs), "dynamics": rows(dyns), "items": rows(items)})
}
func adminPets(c *httpapi.Ctx) error {
var where []string
var params []any
if store.Truthy(c.Q("breed")) {
where = append(where, "p.breed = ?")
params = append(params, c.Q("breed"))
}
if store.Truthy(c.Q("stage")) {
where = append(where, "p.stage = ?")
params = append(params, c.QInt("stage"))
}
whereSQL := ""
if len(where) > 0 {
whereSQL = "WHERE " + strings.Join(where, " AND ")
}
rs, err := c.DB.Query(`SELECT p.*, u1.nickname as user1_name, u2.nickname as user2_name FROM pets p
LEFT JOIN users u1 ON p.created_by = u1.id
LEFT JOIN couples c ON p.couple_id = c.id
LEFT JOIN users u2 ON (CASE WHEN c.user1_id = p.created_by THEN c.user2_id ELSE c.user1_id END) = u2.id
`+whereSQL+` ORDER BY p.level DESC, p.updated_at DESC`, params...)
if err != nil {
return err
}
return c.OK(paged(c, rs))
}
func adminUsers(c *httpapi.Ctx) error {
search := strings.TrimSpace(c.Q("search"))
bind := strings.TrimSpace(c.Q("bind"))
if !c.QHas("bind") {
bind = "all"
}
sort := strings.TrimSpace(c.Q("sort"))
if !c.QHas("sort") {
sort = "newest"
}
sql := "SELECT u.id, u.user_id, u.openid, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.couple_id, u.created_at, COALESCE(u.last_login_at, u.created_at) as last_login_at, u.location, u.last_login_location, u.last_ip, CASE WHEN u.couple_id IS NOT NULL THEN '已绑定' ELSE '未绑定' END as status FROM users u"
var where []string
var params []any
if bind == "bound" {
where = append(where, "u.couple_id IS NOT NULL")
} else if bind == "unbound" {
where = append(where, "u.couple_id IS NULL")
}
if search != "" {
like := "%" + search + "%"
if isNumeric(search) {
where = append(where, "(u.id = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, store.ToInt(search), store.ToInt(search), like, like)
} else {
where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)")
params = append(params, like, like)
}
}
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
if sort == "last_login" {
sql += " ORDER BY COALESCE(u.last_login_at, u.created_at) DESC"
} else {
sql += " ORDER BY u.created_at DESC"
}
rs, err := c.DB.Query(sql, params...)
if err != nil {
return err
}
return c.OK(paged(c, rs))
}
func adminFixLastLogin(c *httpapi.Ctx) error {
res, err := c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL")
if err != nil {
return err
}
n, _ := res.RowsAffected()
return c.OK(M{"success": true, "count": n, "msg": fmt.Sprintf("已补全 %d 个用户的最后登录时间", n)})
}
func adminCleanLocations(c *httpapi.Ctx) error {
res, err := c.DB.Exec(`UPDATE users SET location = '' WHERE location REGEXP '^-?[0-9]+(\\.[0-9]+)?,-?[0-9]+(\\.[0-9]+)?$'`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
return c.OK(M{"success": true, "count": n, "msg": fmt.Sprintf("已清理 %d 个经纬度格式的位置数据", n)})
}
func adminUserDetail(c *httpapi.Ctx) error {
uid := c.QInt("user_id")
if uid == 0 {
return c.Err(400, "缺少用户ID")
}
user, err := c.DB.QueryOne("SELECT id, user_id, openid, username, nickname, avatar, gender, age, birthday, couple_id, created_at, last_login_at, region, location, last_login_location FROM users WHERE id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.Err(404, "用户不存在")
}
if user.Int("couple_id") != 0 {
partner, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, location, last_login_location FROM users WHERE couple_id = ? AND id != ?", user.Int("couple_id"), uid)
user["partner"] = rowOrFalse(partner)
}
logs, err := c.DB.Query("SELECT cl.*, c.title as checkin_title, c.icon as checkin_icon, u2.nickname as creator_name FROM checkin_logs cl LEFT JOIN checkins c ON cl.checkin_id = c.id LEFT JOIN users u2 ON c.created_by = u2.id WHERE cl.user_id = ? ORDER BY cl.created_at DESC LIMIT 100", uid)
if err != nil {
return err
}
user["checkin_logs"] = rows(logs)
checkins, err := c.DB.Query("SELECT * FROM checkins WHERE created_by = ? ORDER BY created_at DESC LIMIT 50", uid)
if err != nil {
return err
}
for _, ck := range checkins {
l, _ := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC LIMIT 30", ck.Int("id"))
ck["logs"] = rows(l)
}
user["checkins"] = rows(checkins)
diaries, err := c.DB.Query("SELECT * FROM diaries WHERE created_by = ? ORDER BY diary_date DESC LIMIT 50", uid)
if err != nil {
return err
}
user["diaries"] = rows(diaries)
albums, err := c.DB.Query("SELECT * FROM albums WHERE created_by = ? ORDER BY created_at DESC LIMIT 50", uid)
if err != nil {
return err
}
user["albums"] = rows(albums)
return c.OK(user)
}
func adminChangePassword(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
if len(c.InStr("new_password")) < 6 {
return c.Err(400, "新密码至少6位")
}
sess := c.AdminSession()
admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID)
if admin != nil && verifyPassword(c.InStr("old_password"), admin.Str("password")) {
if _, err := c.DB.Exec("UPDATE admins SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", hashPassword(c.InStr("new_password")), sess.AdminID); err != nil {
return err
}
return c.OK(M{"success": true})
}
return c.Err(400, "原密码错误")
}
func adminChangeUsername(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
name := c.InTrim("new_username")
if !store.Truthy(name) {
return c.Err(400, "用户名不能为空")
}
sess := c.AdminSession()
if _, err := c.DB.Exec("UPDATE admins SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", name, sess.AdminID); err != nil {
return err
}
sess.Username = name
return c.OK(M{"success": true})
}
func adminUpdateUser(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
userID := c.InInt("user_id")
if userID <= 0 {
return c.Err(400, "用户ID无效")
}
var fields []string
var params []any
if nick := c.InTrim("nickname"); store.Truthy(nick) {
fields = append(fields, "nickname = ?")
params = append(params, nick)
}
if g := c.InStr("gender"); store.Truthy(g) {
fields = append(fields, "gender = ?")
params = append(params, g)
}
if c.InHas("age") {
fields = append(fields, "age = ?")
params = append(params, c.InInt("age"))
}
if b := c.InTrim("birthday"); b != "" {
fields = append(fields, "birthday = ?")
params = append(params, b)
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, userID)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
}
return c.OK(M{"success": true})
}
func adminDeleteUser(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
target := c.InInt("user_id")
if target <= 0 {
return c.Err(400, "用户ID无效")
}
tu, _ := c.DB.QueryOne("SELECT id, couple_id FROM users WHERE id = ?", target)
if tu == nil {
return c.Err(404, "用户不存在")
}
deleteIDs := []any{target}
if cid := tu.Int("couple_id"); cid != 0 {
partners, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? AND id != ?", cid, target)
for _, p := range partners {
deleteIDs = append(deleteIDs, p.Int("id"))
}
for _, t := range []string{"timeline", "diaries", "songs", "checkin_logs", "checkins"} {
c.DB.Exec("DELETE FROM "+t+" WHERE couple_id = ?", cid)
}
albums, _ := c.DB.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums {
c.DB.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id"))
}
c.DB.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
c.DB.Exec("DELETE FROM couples WHERE id = ?", cid)
}
ph := placeholders(len(deleteIDs))
c.DB.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", append(append([]any{}, deleteIDs...), deleteIDs...)...)
c.DB.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", deleteIDs...)
if _, err := c.DB.Exec("DELETE FROM users WHERE id IN ("+ph+")", deleteIDs...); err != nil {
return err
}
msg := "已删除用户"
if len(deleteIDs) > 1 {
msg = fmt.Sprintf("已删除用户及伴侣(共%d个账号)及相关情侣数据", len(deleteIDs))
}
return c.OK(M{"success": true, "msg": msg, "deleted_count": len(deleteIDs)})
}
var unbindStatusLabels = map[string]string{
"pending": "待回应", "agreed": "已同意(已解绑)", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期",
}
func adminUnbindRequests(c *httpapi.Ctx) error {
if err := c.RequireGET(); err != nil {
return err
}
rs, err := c.DB.Query(`
SELECT
ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at,
r.id as requester_id, r.user_id as requester_uid, r.nickname as requester_name, r.avatar as requester_avatar,
c.couple_space_id, c.data_deleted, c.status as couple_status,
u1.nickname as user1_name, u1.user_id as user1_uid,
u2.nickname as user2_name, u2.user_id as user2_uid
FROM unbind_requests ur
JOIN users r ON ur.requester_id = r.id
JOIN couples c ON ur.couple_id = c.id
LEFT JOIN users u1 ON c.user1_id = u1.id
LEFT JOIN users u2 ON c.user2_id = u2.id
WHERE NOT (c.status = 'active' AND ur.status = 'agreed')
UNION
SELECT
NULL as id, c.id as couple_id, 'agreed' as status, NULL as created_at, NULL as responded_at,
NULL as requester_id, NULL as requester_uid, NULL as requester_name, NULL as requester_avatar,
c.couple_space_id, c.data_deleted, c.status as couple_status,
u1.nickname as user1_name, u1.user_id as user1_uid,
u2.nickname as user2_name, u2.user_id as user2_uid
FROM couples c
LEFT JOIN users u1 ON c.user1_id = u1.id
LEFT JOIN users u2 ON c.user2_id = u2.id
WHERE c.status = 'unbound'
AND c.id NOT IN (SELECT DISTINCT couple_id FROM unbind_requests)
ORDER BY created_at DESC`)
if err != nil {
return err
}
filter := c.Q("status")
out := make([]store.Row, 0, len(rs))
for _, r := range rs {
st := r.Str("status")
if filter != "" && st != filter {
continue
}
if l, ok := unbindStatusLabels[st]; ok {
r["status_label"] = l
} else {
r["status_label"] = r["status"]
}
out = append(out, r)
}
return c.OK(paged(c, out))
}
// coupleUserNames 对应 array_map(fn($u) => $u['nickname'] ?: '#' . $u['user_id'], $users) 后 implode(' & ')
func coupleUserNames(users []store.Row) string {
names := make([]string, 0, len(users))
for _, u := range users {
n := u.Str("nickname")
if !store.Truthy(n) {
n = "#" + u.Str("user_id")
}
names = append(names, n)
}
return strings.Join(names, " & ")
}
func adminForceUnbind(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid)
if couple == nil {
return c.Err(404, "情侣不存在")
}
if req, _ := c.DB.QueryOne("SELECT id, requester_id, responded_at FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); req == nil {
return c.Err(400, "双方尚未同意解绑,无法执行操作。请先让双方在微信小程序中完成解绑确认")
}
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE couple_id = ?", cid)
softUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已执行解绑操作(双方已同意),情侣「" + coupleUserNames(users) + "」的关系已解除,所有数据已保留"})
}
func adminRestoreCouple(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound' AND data_deleted = 0", cid)
if couple == nil {
return c.Err(400, "该情侣记录不存在或数据已被删除,无法恢复")
}
u1, u2 := couple.Int("user1_id"), couple.Int("user2_id")
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", u1, u2)
if len(users) < 2 {
return c.Err(400, "用户数据异常,无法恢复情侣关系")
}
c.DB.Exec("UPDATE couples SET status = 'active' WHERE id = ?", cid)
c.DB.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", cid, u1, u2)
c.DB.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid)
return c.OK(M{"success": true, "msg": "已恢复情侣关系「" + coupleUserNames(users) + "」,所有数据已恢复"})
}
func adminDeleteCoupleData(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
couple, _ := c.DB.QueryOne("SELECT * FROM couples WHERE id = ? AND status = 'unbound'", cid)
if couple == nil {
return c.Err(400, "该情侣记录不存在或状态异常,无法删除数据")
}
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Int("user1_id"), couple.Int("user2_id"))
c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid)
executeUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已删除情侣「" + coupleUserNames(users) + "」的所有数据记录"})
}
func adminDeleteContent(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
typ := c.InStr("type")
id, coupleID := c.InInt("id"), c.InInt("couple_id")
if id == 0 || coupleID == 0 {
return c.Err(400, "参数不完整")
}
switch typ {
case "timeline":
c.DB.Exec("DELETE FROM timeline WHERE id = ? AND couple_id = ?", id, coupleID)
case "diary":
c.DB.Exec("DELETE FROM diaries WHERE id = ? AND couple_id = ?", id, coupleID)
case "album":
c.DB.Exec("DELETE FROM photos WHERE album_id = ?", id)
c.DB.Exec("DELETE FROM albums WHERE id = ? AND couple_id = ?", id, coupleID)
case "photo":
if p, _ := c.DB.QueryOne("SELECT path FROM photos WHERE id = ?", id); p != nil {
up.Remove(p.Str("path"))
c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
}
case "song":
c.DB.Exec("DELETE FROM songs WHERE id = ? AND couple_id = ?", id, coupleID)
case "checkin":
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, coupleID)
default:
return c.Err(400, "不支持的内容类型: "+typ)
}
return c.OK(M{"success": true, "msg": "已删除"})
}
func adminContents(c *httpapi.Ctx) error {
typ := c.Q("type")
if !c.QHas("type") {
typ = "diary"
}
coupleID, userID := c.QInt("couple_id"), c.QInt("user_id")
startDate, endDate := c.Q("start_date"), c.Q("end_date")
page, pageSize, offset := pageParams(c, 10, 50)
var where []string
var params []any
if coupleID > 0 {
where = append(where, "t.couple_id = ?")
params = append(params, coupleID)
}
if userID > 0 {
where = append(where, "t.created_by = ?")
params = append(params, userID)
}
if startDate != "" {
where = append(where, "t.created_at >= ?")
params = append(params, startDate+" 00:00:00")
}
if endDate != "" {
where = append(where, "t.created_at <= ?")
params = append(params, endDate+" 23:59:59")
}
whereClause := ""
if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ")
}
coupleList, err := c.DB.Query("SELECT c.id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id ORDER BY c.id DESC")
if err != nil {
return err
}
userList, err := c.DB.Query("SELECT id, user_id, nickname, username FROM users ORDER BY id DESC")
if err != nil {
return err
}
const cols = "t.*, u.nickname as author_name, u.user_id as author_display_id, u.avatar as author_avatar"
var sql, countSQL string
switch typ {
case "diary":
sql = "SELECT " + cols + ", t.couple_id FROM diaries t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM diaries t " + whereClause
case "timeline":
sql = "SELECT " + cols + ", t.couple_id FROM timeline t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM timeline t " + whereClause
case "album":
sql = "SELECT " + cols + ", t.couple_id, (SELECT COUNT(*) FROM photos WHERE album_id = t.id) as photo_count FROM albums t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM albums t " + whereClause
case "photo":
sql = "SELECT " + cols + ", a.couple_id FROM photos t JOIN albums a ON t.album_id = a.id LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM photos t JOIN albums a ON t.album_id = a.id " + whereClause
case "song":
sql = "SELECT " + cols + ", t.couple_id FROM songs t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM songs t " + whereClause
case "checkin":
sql = "SELECT " + cols + ", t.couple_id FROM checkins t LEFT JOIN users u ON t.created_by = u.id " + whereClause + " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
countSQL = "SELECT COUNT(*) as cnt FROM checkins t " + whereClause
default:
return c.Err(400, "不支持的内容类型")
}
total := countOf(c, countSQL, params...)
items, err := c.DB.Query(sql, append(append([]any{}, params...), pageSize, offset)...)
if err != nil {
return err
}
for _, it := range items {
if _, ok := it["images"]; ok && it["images"] != nil {
it["images"] = jsonDecodeList(it["images"])
}
}
return c.OK(M{
"items": rows(items), "total": total, "page": page, "page_size": pageSize,
"total_pages": math.Ceil(float64(total) / float64(pageSize)),
"couples": rows(coupleList), "users": rows(userList),
})
}
func adminResetUserPassword(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
userID := c.InInt("user_id")
pw := c.InStr("new_password")
if userID <= 0 {
return c.Err(400, "用户ID无效")
}
if len(pw) < 6 {
return c.Err(400, "新密码至少6位")
}
if _, err := c.DB.Exec("UPDATE users SET password = ? WHERE id = ?", hashPassword(pw), userID); err != nil {
return err
}
return c.OK(M{"success": true, "message": "密码重置成功"})
}
func adminActivityLog(c *httpapi.Ctx) error {
cid := c.QInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
rs, err := c.DB.Query("SELECT al.*, u.nickname, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.couple_id = ? ORDER BY al.created_at DESC LIMIT 100", cid)
if err != nil {
return err
}
return c.OK(rows(rs))
}
func adminGetGlobalBg(c *httpapi.Ctx) error {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = 'bg_global'")
if row == nil {
return c.OK(M{"bg_image": nil})
}
return c.OK(M{"bg_image": row["value"]})
}
func adminSetGlobalBg(c *httpapi.Ctx) error {
fh := c.File("bg")
if fh == nil {
return c.Err(400, "没有上传文件")
}
res := up.Handle(fh, "", "global")
if res.Error != "" {
return c.Err(400, res.Error)
}
if _, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES ('bg_global', ?) ON DUPLICATE KEY UPDATE `value` = ?, updated_at = CURRENT_TIMESTAMP", res.Path, res.Path); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
func adminClearGlobalBg(c *httpapi.Ctx) error {
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = 'bg_global'"); err != nil {
return err
}
return c.OK(M{"success": true})
}
func adminCoupleBgList(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT c.id as couple_id, c.couple_space_id, u1.nickname as user1_name, u2.nickname as user2_name, CONCAT(u1.nickname, ' & ', u2.nickname) as couple_name, s1.value as bg_image FROM couples c LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u2 ON c.user2_id = u2.id LEFT JOIN settings s1 ON s1.`key` = CONCAT('bg_', c.id) ORDER BY c.id DESC")
if err != nil {
return err
}
return c.OK(rows(rs))
}
func adminClearCoupleBg(c *httpapi.Ctx) error {
cid := c.InInt("couple_id")
if cid == 0 {
return c.Err(400, "缺少情侣ID")
}
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", fmt.Sprintf("bg_%d", cid), fmt.Sprintf("bgver_%d", cid)); err != nil {
return err
}
return c.OK(M{"success": true})
}
+346
View File
@@ -0,0 +1,346 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
func registerAlbum(r *httpapi.Router) {
r.Handle("album_list", albumList)
r.Handle("album_add", albumAdd)
r.Handle("album_update", albumUpdate)
r.Handle("album_delete", albumDelete)
r.Handle("set_album_cover", setAlbumCover)
r.Handle("photos", photos)
r.Handle("photo_upload", photoUpload)
r.Handle("photo_delete", photoDelete)
r.Handle("photo_add", photoAdd)
}
// coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ?
func coupleSpaceID(c *httpapi.Ctx, cid int64) string {
row, _ := c.DB.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid)
if row == nil {
return ""
}
return row.Str("couple_space_id")
}
func albumList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK(demoData("album"))
}
items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC", ids[0], ids[1])
if err != nil {
return err
}
for _, it := range items {
if store.Truthy(it["latest_photo"]) {
it["cover"] = it["latest_photo"]
}
}
return c.OK(rows(items))
}
func albumAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
return err
}
lat, lng := c.InFloat("lat"), c.InFloat("lng")
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid)
if err != nil {
return err
}
title := c.InStr("title")
actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "新相册「" + mbTruncate(title, 20) + "」",
"thing2": "TA创建了新相册,快来一起看看吧 📸",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/albums/albums", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func albumUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
return err
}
title, desc, sort, id := c.In("title"), inOr(c, "description", ""), inOr(c, "sort_order", 0), c.In("id")
hasCover, hasLat := c.InHas("cover"), c.InHas("lat")
switch {
case hasCover && hasLat:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, cover = ?, sort_order = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, c.In("cover"), sort, c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), id, ids[0], ids[1])
case hasCover:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, cover = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, c.In("cover"), sort, id, ids[0], ids[1])
case hasLat:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, sort_order = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, sort, c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), id, ids[0], ids[1])
default:
_, err = c.DB.Exec("UPDATE albums SET title = ?, description = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)",
title, desc, sort, id, ids[0], ids[1])
}
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func albumDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.In("id")
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil {
return c.Err(404, "相册不存在")
}
if cnt, _ := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE album_id = ?", id); cnt != nil && cnt.Int("cnt") > 0 {
return c.Err(400, "请先删除相册中的全部照片")
}
if _, err := c.DB.Exec("DELETE FROM albums WHERE id = ?", id); err != nil {
return err
}
return c.OK(M{"success": true})
}
func setAlbumCover(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
ids := coupleUserIds(c, 0)
albumID := store.ToInt(c.Form("album_id"))
var a store.Row
if len(ids) == 2 {
a, _ = c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, ids[0], ids[1])
}
if a == nil {
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
}
spaceID := coupleSpaceID(c, c.CoupleID())
fh := c.File("cover")
if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" {
if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
return c.ErrCode(400, "上传失败: "+orDefault(res.Error, "未知错误"), upload.ErrHandleFailed)
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func photos(c *httpapi.Ctx) error {
albumID := c.Q("album_id")
if albumID == "" {
albumID = "0"
}
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK([]any{})
}
items, err := c.DB.Query("SELECT p.* FROM photos p JOIN albums a ON p.album_id = a.id WHERE a.created_by IN (?, ?) AND p.album_id = ? ORDER BY p.sort_order ASC, p.created_at DESC", ids[0], ids[1], albumID)
if err != nil {
return err
}
return c.OK(rows(items))
}
// setFirstCover 相册第一张照片时自动设为封面
func setFirstCover(c *httpapi.Ctx, albumID any, path string) {
if cnt, _ := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE album_id = ?", albumID); cnt != nil && cnt.Int("cnt") == 1 {
c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ? AND (cover IS NULL OR cover = '')", path, albumID)
}
}
func photoUpload(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
uid := c.UID()
if uid == 0 {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
}
cid := c.CoupleID()
if cid == 0 {
return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound)
}
spaceID := coupleSpaceID(c, cid)
albumID := c.Form("album_id")
if albumID == "" {
albumID = "0"
}
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil {
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
}
description := c.Form("description")
if err := checkTextOr400(c, strings.TrimSpace(description)); err != nil {
return err
}
uploaded := []string{}
errors := []string{}
files := c.Files("photos")
if len(files) == 0 {
files = c.Files("file")
}
if len(files) == 0 {
// 兼容某些安卓端直接传二进制:从原始请求体读取
raw := c.Body()
if len(raw) > 100 {
res := up.SaveRaw(raw, "", "jpg", "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" {
c.DB.Exec("INSERT INTO photos (album_id, path, description, created_by) VALUES (?, ?, ?, ?)", albumID, res.Path, description, uid)
uploaded = append(uploaded, res.Path)
setFirstCover(c, albumID, res.Path)
} else if strings.Contains(res.Error, "违规") {
errors = append(errors, "所发布内容含违规信息")
}
}
if len(uploaded) == 0 {
return c.ErrCode(400, "未接收到文件,请重试", upload.ErrNoFileReceived)
}
}
for _, fh := range files {
res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos"))
if res.Path != "" {
c.DB.Exec("INSERT INTO photos (album_id, path, description, taken_at, location, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
albumID, res.Path, description, c.Form("taken_at"), c.Form("location"), c.Form("activity"), c.Form("batch_id"), uid)
uploaded = append(uploaded, res.Path)
setFirstCover(c, albumID, res.Path)
} else {
errors = append(errors, fh.Filename+": "+orDefault(res.Error, "未知错误"))
}
}
if len(uploaded) == 0 && len(errors) > 0 {
return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed)
}
if len(uploaded) > 0 {
actID := logActivity(c.DB, cid, uid, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)), "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "相册有更新",
"thing2": fmt.Sprintf("TA上传了%d张新照片 📸", len(uploaded)),
"date3": today(),
"thing4": "点击查看详情",
}, "pages/albums/albums", actID, uid)
}
}
return c.OK(M{"success": true, "uploaded": len(uploaded), "errors": errors})
}
func photoDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.In("id")
photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if photo != nil {
albumID := photo.Int("album_id")
up.Remove(photo.Str("path"))
c.DB.Exec("DELETE FROM photos WHERE id = ?", id)
album, _ := c.DB.QueryOne("SELECT cover FROM albums WHERE id = ?", albumID)
if album != nil && album.Str("cover") == photo.Str("path") && !album.IsNull("cover") {
next, _ := c.DB.QueryOne("SELECT path FROM photos WHERE album_id = ? ORDER BY created_at ASC LIMIT 1", albumID)
newCover := ""
if next != nil {
newCover = next.Str("path")
}
c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", newCover, albumID)
}
}
return c.OK(M{"success": true})
}
func photoAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
if !c.InBool("album_id") || !c.InBool("path") {
return c.Err(400, "参数不完整")
}
albumID := c.In("album_id")
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil {
return c.Err(404, "相册不存在")
}
uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("description")+" "+c.InStr("activity"))); err != nil {
return err
}
lat, lng := c.InFloat("lat"), c.InFloat("lng")
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
path := c.In("path")
id, err := c.DB.Insert("INSERT INTO photos (album_id, path, description, taken_at, location, lat, lng, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
albumID, path, inOr(c, "description", ""), inOr(c, "taken_at", ""), location, lat, lng, inOr(c, "activity", ""), inOr(c, "batch_id", ""), uid)
if err != nil {
return err
}
setFirstCover(c, albumID, c.InStr("path"))
actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "相册有更新",
"thing2": "TA上传了新照片 📸",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/albums/albums", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id), "path": path})
}
+436
View File
@@ -0,0 +1,436 @@
package handler
import (
"fmt"
"time"
"past/internal/httpapi"
"past/internal/store"
)
func registerCheckin(r *httpapi.Router) {
r.Handle("checkin_list", checkinList)
r.Handle("checkin_add", checkinAdd)
r.Handle("checkin_do", checkinDo)
r.Handle("checkin_uncheck", checkinUncheck)
r.Handle("checkin_detail", checkinDetail)
r.Handle("checkin_stats", checkinStats)
r.Handle("checkin_toggle", checkinToggle)
r.Handle("checkin_delete", checkinDelete)
}
// weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一
func weekMonday() string {
t := time.Now()
wd := int(t.Weekday())
if wd == 0 {
wd = 7
}
return t.AddDate(0, 0, -(wd - 1)).Format("2006-01-02")
}
func monthStart() string { return time.Now().Format("2006-01") + "-01" } // date('Y-m-01')
func yearStart() string { return time.Now().Format("2006") + "-01-01" } // date('Y-01-01')
var checkinTypeLabels = map[string]string{"both": "双方", "first": "第一视角", "her": "她打卡"}
// first10 对应 $t ? substr($t, 0, 10) : null;返回 (值, 是否非空)
func first10(v any) (string, bool) {
s := store.ToStr(v)
if !store.Truthy(s) {
return "", false
}
if len(s) > 10 {
s = s[:10]
}
return s, true
}
// periodDone 周期完成判定
func periodDone(freq string, last string, has bool, today, monday, month string) bool {
switch freq {
case "once":
return has
case "unlimited":
return false
case "monthly":
return has && last >= month
case "weekly":
return has && last >= monday
default:
return has && last == today
}
}
// decorateCheckin 为 checkin_list / checkin_detail 的单条记录补充视角字段
func decorateCheckin(item store.Row, uid int64, withAvatar bool) {
isCreator := uid == item.Int("created_by")
freq := store.ToStr(item["frequency"])
if item.IsNull("frequency") {
freq = "daily"
}
typ := store.ToStr(item["type"])
if item.IsNull("type") {
typ = "both"
}
label, ok := checkinTypeLabels[typ]
if !ok {
label = "双方"
}
item["type_label"] = label
var myTime, partnerTime any
if isCreator {
myTime, partnerTime = item["my_checkin_time"], item["partner_checkin_time"]
} else {
myTime, partnerTime = item["partner_checkin_time"], item["my_checkin_time"]
}
td, mon, ms := today(), weekMonday(), monthStart()
myLast, myHas := first10(myTime)
partnerLast, partnerHas := first10(partnerTime)
myDone := periodDone(freq, myLast, myHas, td, mon, ms)
partnerDone := periodDone(freq, partnerLast, partnerHas, td, mon, ms)
item["my_done_today"] = myDone
item["partner_done_today"] = partnerDone
switch typ {
case "first":
item["can_checkin"] = isCreator && !myDone
case "her":
item["can_checkin"] = !isCreator && !partnerDone
default:
item["can_checkin"] = !myDone
}
completed := 0
if typ == "both" {
if myDone && partnerDone {
completed = 1
}
} else if (typ == "first" && myDone) || (typ != "first" && partnerDone) {
completed = 1
}
item["is_completed"] = completed
// 注意:PHP 的 checkin_list 会把时间字段换成当前视角,checkin_detail 不换(withAvatar 仅 list 为 true)
if withAvatar {
item["my_checkin_time"] = myTime
item["partner_checkin_time"] = partnerTime
}
orZero := func(v any) any {
if v == nil {
return 0
}
return v
}
orEmpty := func(v any) any {
if v == nil {
return ""
}
return v
}
// 与 PHP 逐行顺序赋值保持一致:非创建者视角下 my_* 先被覆盖,随后 partner_* 读到的是已覆盖后的值
// (PHP 原有行为,对方的 streak/照片/备注会显示为自己的值;为保持 API 一致这里原样复刻)
swap := func(my, partner string, def func(any) any) {
if isCreator {
item[my] = def(item[my])
item[partner] = def(item[partner])
} else {
item[my] = def(item[partner])
item[partner] = def(item[my])
}
}
swap("my_streak", "partner_streak", orZero)
swap("my_photo", "partner_photo", orEmpty)
swap("my_note", "partner_note", orEmpty)
if withAvatar {
ca, cn := orEmpty(item["creator_avatar"]), orEmpty(item["creator_name"])
if isCreator {
item["my_checkin_avatar"], item["my_checkin_name"] = ca, cn
item["partner_checkin_avatar"], item["partner_checkin_name"] = "", ""
} else {
item["my_checkin_avatar"], item["my_checkin_name"] = "", ""
item["partner_checkin_avatar"], item["partner_checkin_name"] = ca, cn
}
}
}
func checkinList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5})
}
uid := c.UID()
items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1])
if err != nil {
return err
}
for _, it := range items {
decorateCheckin(it, uid, true)
}
total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?)", ids[0], ids[1])
var logTotal int64
if cid := c.CoupleID(); cid != 0 {
logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid)
}
return c.OK(M{"items": rows(items), "week_both_count": 0, "total": total, "checkin_log_total": logTotal})
}
func countC(c *httpapi.Ctx, sql string, args ...any) int64 {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return 0
}
return r.Int("c")
}
func checkinAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if err := checkTextOr400(c, c.InTrim("title")); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO checkins (couple_id, title, icon, type, frequency, is_completed, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "icon", "✅"), inOr(c, "type", "dual"), inOr(c, "frequency", "daily"), 0, inOr(c, "sort_order", 0), uid)
if err != nil {
return err
}
logActivity(c.DB, cid, uid, "checkin_create", "添加了打卡项「"+c.InStr("title")+"」", "")
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
// loadCheckinItem 查询打卡项(未绑定或不存在时已写出 404)
func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) {
ids := coupleUserIds(c, 0)
var item store.Row
if len(ids) == 2 {
var err error
item, err = c.DB.QueryOne("SELECT * FROM checkins WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return nil, err
}
}
if item == nil {
return nil, c.Err(404, "打卡项不存在")
}
return item, nil
}
func checkinDo(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, uid := c.CoupleID(), c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少打卡ID")
}
item, err := loadCheckinItem(c, id)
if err != nil {
return err
}
isCreator := uid == item.Int("created_by")
td, nowS := today(), now()
freq := store.ToStr(item["frequency"])
if item.IsNull("frequency") {
freq = "daily"
}
typ := store.ToStr(item["type"])
if item.IsNull("type") {
typ = "both"
}
mon, ms := weekMonday(), monthStart()
if typ == "first" && !isCreator {
return c.Err(403, "第一视角打卡仅创建者可操作")
}
if typ == "her" && isCreator {
return c.Err(403, "她打卡仅对方可操作")
}
myTimeField, myStreakField, myPhotoField, myNoteField, myDoneField := "my_checkin_time", "my_streak", "my_photo", "my_note", "completed_by_me"
partnerTimeField := "partner_checkin_time"
if !isCreator {
myTimeField, myStreakField, myPhotoField, myNoteField, myDoneField = "partner_checkin_time", "partner_streak", "partner_photo", "partner_note", "completed_by_partner"
partnerTimeField = "my_checkin_time"
}
myLastTime := store.ToStr(item[myTimeField])
myLast, myHas := first10(item[myTimeField])
if store.Truthy(myLastTime) {
if t, err := parseDate(myLastTime); err == nil && time.Since(t) < 5*time.Second {
return c.JSON(429, M{"error": "操作太快,请稍后再试", "cooldown": true})
}
}
if periodDone(freq, myLast, myHas, td, mon, ms) {
return c.OK(M{"error": "本期已打卡", "already_done": true})
}
yesterday := daysAgo(1)
myStreak := int64(1)
if myHas && myLast == yesterday {
myStreak = item.Int(myStreakField) + 1
}
photo := inOr(c, "photo", "")
note := inOr(c, "note", "")
if err := checkTextOr400(c, store.ToStr(note)); err != nil {
return err
}
if _, err := c.DB.Exec(fmt.Sprintf("UPDATE checkins SET %s = 1, %s = ?, %s = ?, %s = ?, %s = ? WHERE id = ?", myDoneField, myTimeField, myStreakField, myPhotoField, myNoteField),
nowS, myStreak, photo, note, id); err != nil {
return err
}
allDone := true
if typ == "both" {
partnerLast, partnerHas := first10(item[partnerTimeField])
allDone = periodDone(freq, partnerLast, partnerHas, td, mon, ms)
}
if allDone {
c.DB.Exec("UPDATE checkins SET is_completed = 1, completed_at = ? WHERE id = ?", nowS, id)
}
title := item.Str("title")
actID := logActivity(c.DB, cid, uid, "checkin_do", "打卡「"+title+"」", "")
c.DB.Exec("INSERT INTO checkin_logs (checkin_id, couple_id, user_id, photo, note) VALUES (?, ?, ?, ?, ?)", id, cid, uid, photo, note)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "打卡「" + mbTruncate(title, 20) + "」",
"thing2": fmt.Sprintf("TA已完成打卡,坚持第%d天 💪", myStreak),
"date3": td,
"thing4": "点击查看详情",
}, "pages/punch/punch", actID, uid)
}
return c.OK(M{"success": true, "my_streak": myStreak, "both_done": allDone, "type": typ})
}
func checkinUncheck(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少打卡ID")
}
item, err := loadCheckinItem(c, id)
if err != nil {
return err
}
sql := "UPDATE checkins SET completed_by_me = 0, my_checkin_time = NULL, my_streak = 0, my_photo = '', my_note = '', is_completed = 0 WHERE id = ?"
if uid != item.Int("created_by") {
sql = "UPDATE checkins SET completed_by_partner = 0, partner_checkin_time = NULL, partner_streak = 0, partner_photo = '', partner_note = '', is_completed = 0 WHERE id = ?"
}
if _, err := c.DB.Exec(sql, id); err != nil {
return err
}
return c.OK(M{"success": true})
}
func checkinDetail(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK([]any{})
}
id := c.QInt("id")
if id == 0 {
return c.Err(400, "缺少打卡ID")
}
item, err := c.DB.QueryOne("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.id = ? AND c.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "打卡项不存在")
}
uid := c.UID()
decorateCheckin(item, uid, false)
td, mon, ms, ys := today(), weekMonday(), monthStart(), yearStart()
logs, err := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC", id)
if err != nil {
return err
}
var myT, myW, myM, myY, pT, pW, pM, pY int
for _, l := range logs {
d, _ := first10(l["created_at"])
mine := l.Int("user_id") == uid
inc := func(cond bool, m, p *int) {
if cond {
if mine {
*m++
} else {
*p++
}
}
}
inc(d == td, &myT, &pT)
inc(d >= mon, &myW, &pW)
inc(d >= ms, &myM, &pM)
inc(d >= ys, &myY, &pY)
}
return c.OK(M{
"item": item, "logs": rows(logs),
"my_today": myT, "my_week": myW, "my_month": myM, "my_year": myY,
"partner_today": pT, "partner_week": pW, "partner_month": pM, "partner_year": pY,
})
}
func checkinStats(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK([]any{})
}
td, ws := today(), weekMonday()
return c.OK(M{
"week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time >= ?", ids[0], ids[1], ws),
"week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time >= ?", ids[0], ids[1], ws),
"week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws),
"today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time LIKE ?", ids[0], ids[1], td+"%"),
"today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%"),
})
}
func checkinToggle(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
id := c.In("id")
cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid)
newStatus := int64(1)
if cur != nil && store.Truthy(cur["is_completed"]) {
newStatus = 0
}
var completedAt any
if newStatus == 1 {
completedAt = now()
}
if _, err := c.DB.Exec("UPDATE checkins SET is_completed = ?, completed_at = ? WHERE id = ? AND couple_id = ?", newStatus, completedAt, id, cid); err != nil {
return err
}
title := ""
if it, _ := c.DB.QueryOne("SELECT title FROM checkins WHERE id = ?", id); it != nil {
title = it.Str("title")
}
verb := "取消了"
if newStatus == 1 {
verb = "完成了"
}
logActivity(c.DB, cid, c.UID(), "checkin_toggle", verb+"打卡「"+title+"」", "")
return c.OK(M{"success": true, "is_completed": newStatus})
}
func checkinDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
id := c.In("id")
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
if _, err := c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, cid); err != nil {
return err
}
return c.OK(M{"success": true})
}
+431
View File
@@ -0,0 +1,431 @@
// Package handler 按模块实现全部 action,与 backend/api/index.php 一一对应。
package handler
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf16"
"unicode/utf8"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
type M = httpapi.M
// deps 注入的依赖(上传器等)
var up *upload.Uploader
// Register 注册全部 action
func Register(r *httpapi.Router, uploader *upload.Uploader) {
up = uploader
registerUser(r)
registerCouple(r)
registerTimeline(r)
registerAlbum(r)
registerDiary(r)
registerSongs(r)
registerCheckin(r)
registerPeriod(r)
registerBg(r)
registerSite(r)
registerActivity(r)
registerSubscribe(r)
registerPet(r)
registerAdmin(r)
}
func now() string { return time.Now().Format("2006-01-02 15:04:05") }
// needLoginRecord 对应“从未登录过,或距上次登录超过 5 分钟”
func needLoginRecord(lastLoginAt string) bool {
if lastLoginAt == "" {
return true
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", lastLoginAt, time.Local)
return err != nil || time.Since(t) > 300*time.Second
}
func today() string { return time.Now().Format("2006-01-02") }
func daysAgo(n int) string { return time.Now().AddDate(0, 0, -n).Format("2006-01-02") }
// rows 保证列表非 nil(JSON 输出 [] 而非 null)
func rows(r []store.Row) []store.Row {
if r == nil {
return []store.Row{}
}
return r
}
// generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id
func generateUserId(db store.Querier) (string, error) {
row, err := db.QueryOne("SELECT `value` FROM settings WHERE `key` = 'last_user_id'")
if err != nil {
return "", err
}
var next int64
if row != nil && row.Int("value") >= 520001 {
next = row.Int("value") + 1
} else {
mx, err := db.QueryOne("SELECT MAX(CAST(user_id AS SIGNED)) as max_id FROM users WHERE user_id LIKE '520%'")
if err != nil {
return "", err
}
if mx != nil && mx.Int("max_id") != 0 {
next = mx.Int("max_id") + 1
} else {
next = 520001
}
}
if _, err := db.Exec("REPLACE INTO settings (`key`, `value`, updated_at) VALUES ('last_user_id', ?, CURRENT_TIMESTAMP)", fmt.Sprint(next)); err != nil {
return "", err
}
return fmt.Sprint(next), nil
}
// generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT)
func generateCoupleSpaceId(coupleID int64) string {
return fmt.Sprintf("CP%06d", coupleID)
}
// logActivity 对应 logActivity(),返回 activity id
func logActivity(db store.Querier, coupleID, userID int64, actionType, description, detail string) int64 {
id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, detail)
return id
}
// coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空
func coupleUserIds(c *httpapi.Ctx, coupleID int64) []int64 {
if coupleID == 0 {
coupleID = c.CoupleID()
if coupleID == 0 {
return nil
}
}
row, _ := c.DB.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", coupleID)
if row == nil {
return nil
}
return []int64{row.Int("user1_id"), row.Int("user2_id")}
}
// partnerUserId 对应 getPartnerUserId($userId);无则 0
func partnerUserId(db store.Querier, userID int64) int64 {
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID)
if u == nil || u.Int("couple_id") == 0 {
return 0
}
cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id"))
if cp == nil {
return 0
}
if cp.Int("user1_id") == userID {
return cp.Int("user2_id")
}
return cp.Int("user1_id")
}
// userOpenId 对应 getUserOpenId($uid)
func userOpenId(db store.Querier, uid int64) string {
if uid == 0 {
return ""
}
row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid)
if row == nil {
return ""
}
return row.Str("openid")
}
// softUnbind 对应 softUnbind($db, $cid):仅标记解除,数据保留
func softUnbind(db store.Querier, cid int64) {
db.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE couple_id = ? AND status IN ('pending','agreed')", cid)
db.Exec("UPDATE couples SET status = 'unbound', data_deleted = 0 WHERE id = ?", cid)
db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid)
}
// executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料
func executeUnbind(db store.Querier, cid int64) {
db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid)
db.Exec("DELETE FROM diaries WHERE couple_id = ?", cid)
db.Exec("DELETE FROM songs WHERE couple_id = ?", cid)
db.Exec("DELETE FROM checkin_logs WHERE couple_id = ?", cid)
db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid)
albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums {
photos, _ := db.Query("SELECT path FROM photos WHERE album_id = ?", a.Int("id"))
for _, p := range photos {
up.Remove(p.Str("path"))
}
db.Exec("DELETE FROM photos WHERE album_id = ?", a.Int("id"))
}
db.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid)
users, _ := db.Query("SELECT id FROM users WHERE couple_id = ?", cid)
if len(users) > 0 {
ids := make([]any, 0, len(users))
for _, u := range users {
ids = append(ids, u.Int("id"))
}
ph := placeholders(len(ids))
args := append(append([]any{}, ids...), ids...)
db.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", args...)
db.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", ids...)
}
db.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid)
db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid)
sp, _ := db.QueryOne("SELECT couple_space_id FROM couples WHERE id = ?", cid)
if sp != nil && sp.Str("couple_space_id") != "" {
dir := filepath.Join(up.Dir, "couples", sp.Str("couple_space_id"))
if st, err := os.Stat(dir); err == nil && st.IsDir() {
os.RemoveAll(dir)
}
}
db.Exec("DELETE FROM couples WHERE id = ?", cid)
}
// createDefaultAlbums 对应 createDefaultAlbums()
func createDefaultAlbums(db store.Querier, coupleID, userID int64) {
defaults := [][2]string{
{"日常甜蜜", "我们的小日常"},
{"旅行记录", "一起走过的风景"},
{"美食打卡", "一起吃的每一顿饭"},
{"生活碎片", "生活中的点点滴滴"},
{"节日纪念", "特别的节日和纪念日"},
{"自拍合照", "我们的精彩瞬间"},
}
for i, d := range defaults {
db.Exec("INSERT INTO albums (couple_id, title, description, sort_order, created_by) VALUES (?, ?, ?, ?, ?)", coupleID, d[0], d[1], i, userID)
}
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// resolveLoginLocation 对应 resolveLoginLocation($input, $fallback)
func resolveLoginLocation(c *httpapi.Ctx, fallback string) string {
raw := strings.TrimSpace(c.InStr("location"))
if raw == "" {
raw = strings.TrimSpace(fallback)
}
if raw == "" {
return ""
}
if strings.Contains(raw, ",") {
parts := strings.Split(raw, ",")
if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) {
lat := store.ToFloat(strings.TrimSpace(parts[0]))
lng := store.ToFloat(strings.TrimSpace(parts[1]))
if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" {
return addr
}
}
}
return raw
}
// isNumeric 对应 PHP is_numeric
func isNumeric(s string) bool {
if s == "" {
return false
}
var f float64
_, err := fmt.Sscanf(s, "%g", &f)
if err != nil {
return false
}
// Sscanf 允许前缀匹配,再严格检查字符集
for _, ch := range s {
if !(ch >= '0' && ch <= '9') && ch != '.' && ch != '-' && ch != '+' && ch != 'e' && ch != 'E' {
return false
}
}
return true
}
// demoData 对应 getDemoData($module)
func demoData(module string) []M {
ts := now()
switch module {
case "timeline":
return []M{
{"id": -1, "title": "第一次相遇", "content": "还记得那天阳光正好,你穿着白色的衬衫,在图书馆的角落里安静地看书。", "event_date": daysAgo(30), "location": "图书馆", "images": []any{}, "icon": "❤️", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "第一次约会", "content": "一起看了场电影,吃了顿美味的晚餐,聊了很多很多。", "event_date": daysAgo(25), "location": "电影院", "images": []any{}, "icon": "🎬", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "一起做饭", "content": "第一次一起下厨,虽然把厨房弄得一团糟,但很开心。", "event_date": daysAgo(20), "location": "家", "images": []any{}, "icon": "🍳", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "公园散步", "content": "傍晚在公园散步,夕阳很美,你更美。", "event_date": daysAgo(15), "location": "公园", "images": []any{}, "icon": "🌅", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "纪念日", "content": "属于我们的特别日子,值得永远铭记。", "event_date": daysAgo(10), "location": "", "images": []any{}, "icon": "🎉", "sort_order": 0, "created_by": 0, "created_at": ts},
}
case "album":
return []M{
{"id": -1, "title": "甜蜜日常", "description": "记录我们的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "旅行记忆", "description": "一起走过的风景", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "美食打卡", "description": "一起吃的每一顿饭", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "生活碎片", "description": "生活中的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "我们的故事", "description": "从开始到现在", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
}
case "diary":
return []M{
{"id": -1, "title": "初识", "content": "今天在图书馆遇见了你,心跳加速的感觉真好。", "mood": "😊", "weather": "☀️", "diary_date": daysAgo(5), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -2, "title": "美好的一天", "content": "和你在一起的时间总是过得太快,期待下次见面。", "mood": "🥰", "weather": "☀️", "diary_date": daysAgo(4), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -3, "title": "小确幸", "content": "收到你的消息,一整天的心情都变好了。", "mood": "💕", "weather": "⛅", "diary_date": daysAgo(3), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -4, "title": "想念", "content": "今天没有见到你,好想你呀。", "mood": "😔", "weather": "🌧️", "diary_date": daysAgo(2), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -5, "title": "期待未来", "content": "想象和你一起的每一个明天,都是那么美好。", "mood": "💖", "weather": "☀️", "diary_date": daysAgo(1), "images": []any{}, "created_by": 0, "created_at": ts},
}
case "songs":
return []M{
{"id": -1, "song_name": "告白气球", "artist": "周杰伦", "note": "第一次约会时听的歌", "created_by": 0, "created_at": ts},
{"id": -2, "song_name": "小情歌", "artist": "苏打绿", "note": "你最喜欢的一首歌", "created_by": 0, "created_at": ts},
{"id": -3, "song_name": "想你的夜", "artist": "关喆", "note": "每次想你的时候都会听", "created_by": 0, "created_at": ts},
{"id": -4, "song_name": "有点甜", "artist": "汪苏泷", "note": "我们的主题曲", "created_by": 0, "created_at": ts},
{"id": -5, "song_name": "最美的期待", "artist": "周笔畅", "note": "对未来充满期待", "created_by": 0, "created_at": ts},
}
case "checkin":
return []M{
{"id": -1, "title": "每天说爱你", "icon": "❤️", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "一起运动", "icon": "🏃", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "早睡打卡", "icon": "🌙", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "喝水提醒", "icon": "💧", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "看书学习", "icon": "📚", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
}
}
return []M{}
}
// inOr 对应 $input['key'] ?? $def(null 也视为未设置)
func inOr(c *httpapi.Ctx, key string, def any) any {
if v := c.In(key); v != nil {
return v
}
return def
}
// mbTruncate 对应 mb_strlen($s) > n ? mb_substr($s, 0, n) . '...' : $s
func mbTruncate(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n]) + "..."
}
return s
}
// jsonEncode 对应 PHP json_encode($v) 默认标志(存库用):转义 "/" 为 "\/",非 ASCII 转 \uXXXX(含代理对),
// 使 Go 写入的 JSON 字段与 PHP 历史数据字节一致。
func jsonEncode(v any) string {
if v == nil {
return "null"
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "null"
}
return phpEscapeJSON(strings.TrimRight(buf.String(), "\n"))
}
// phpEscapeJSON 把 Go 的 JSON 文本转换成 PHP json_encode 默认输出形式
func phpEscapeJSON(s string) string {
var sb strings.Builder
inStr := false
for i := 0; i < len(s); {
ch := s[i]
if !inStr {
if ch == '"' {
inStr = true
}
sb.WriteByte(ch)
i++
continue
}
switch {
case ch == '\\':
// 已转义序列(Go 产生的 \uXXXX / \" / \\ / \n 等)原样保留
if i+1 < len(s) {
sb.WriteByte(ch)
sb.WriteByte(s[i+1])
i += 2
} else {
sb.WriteByte(ch)
i++
}
case ch == '"':
inStr = false
sb.WriteByte(ch)
i++
case ch == '/':
sb.WriteString("\\/")
i++
case ch >= 0x80:
r, size := utf8.DecodeRuneInString(s[i:])
if r >= 0x10000 {
r1, r2 := utf16.EncodeRune(r)
fmt.Fprintf(&sb, "\\u%04x\\u%04x", r1, r2)
} else {
fmt.Fprintf(&sb, "\\u%04x", r)
}
i += size
default:
sb.WriteByte(ch)
i++
}
}
return sb.String()
}
// jsonDecodeList 对应 json_decode($s, true) ?: []:解析失败或为空返回空数组
func jsonDecodeList(v any) any {
s := store.ToStr(v)
if s == "" {
return []any{}
}
var out any
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
return []any{}
}
switch x := out.(type) {
case []any:
if len(x) == 0 {
return []any{}
}
case map[string]any:
if len(x) == 0 {
return []any{}
}
case bool:
if !x {
return []any{}
}
case float64:
if x == 0 {
return []any{}
}
case string:
if x == "" || x == "0" {
return []any{}
}
}
return out
}
// countRaw 对应 $stmt->fetch()['cnt'] 原样输出(未经 intval,PHP 8.0 下为字符串)
func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return nil
}
return r[col]
}
// randInt 对应 random_int(min, max)
func randInt(min, max int) int { return min + rand.Intn(max-min+1) }
+590
View File
@@ -0,0 +1,590 @@
package handler
import (
"fmt"
"math"
"regexp"
"strings"
"time"
"past/internal/geo"
"past/internal/httpapi"
"past/internal/store"
)
var targetIDRe = regexp.MustCompile(`^520\d{3,}$`)
func registerCouple(r *httpapi.Router) {
r.Handle("generate_invite_link", generateInviteLink)
r.Handle("check_invite_data_status", checkInviteDataStatus)
r.Handle("check_invite_code", checkInviteCode)
r.Handle("invite_by_phone", inviteByPhone)
r.Handle("invite_partner", invitePartner)
r.Handle("my_invitations", myInvitations)
r.Handle("respond_invitation", respondInvitation)
r.Handle("accept_share_invite", acceptShareInvite)
r.Handle("couple_info", coupleInfo)
r.Handle("update_couple_info", updateCoupleInfo)
r.Handle("unbind_request", unbindRequest)
r.Handle("unbind_respond", unbindRespond)
r.Handle("unbind_cancel", unbindCancel)
r.Handle("unbind_status", unbindStatus)
r.Handle("unbind_couple", unbindCouple)
}
// parseDate 对应 new DateTime($str)(仅支持常见格式)
func parseDate(s string) (time.Time, error) {
s = strings.TrimSpace(s)
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02", "2006/01/02", "2006-1-2", "2006.01.02"} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("DateTime::__construct(): Failed to parse time string (%s)", s)
}
// diffDays 对应 DateTime::diff()->days(整天数,绝对值)
func diffDays(a, b time.Time) int64 {
d := a.Sub(b)
if d < 0 {
d = -d
}
return int64(math.Floor(d.Hours() / 24))
}
// birthdayCountdown 按日历日计算下一次生日。生日当天仍是 0 天,不会因为当天零点已过而跳到下一年。
func birthdayCountdown(nowT, bday time.Time, currentAge int64) (time.Time, int64, int64) {
today := time.Date(nowT.Year(), nowT.Month(), nowT.Day(), 0, 0, 0, 0, nowT.Location())
next := time.Date(today.Year(), bday.Month(), bday.Day(), 0, 0, 0, 0, nowT.Location())
if next.Before(today) {
next = next.AddDate(1, 0, 0)
}
// 用 UTC 中的日历日做差,避免当前时分秒导致“明天生日却显示 0 天”。
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
nextDate := time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, time.UTC)
daysLeft := int64(nextDate.Sub(todayDate) / (24 * time.Hour))
return next, daysLeft, currentAge + 1
}
func generateInviteLink(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user != nil && user.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
if ex, _ := c.DB.QueryOne("SELECT code FROM invite_links WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", uid); ex != nil {
return c.OK(M{"success": true, "code": ex.Str("code"), "url": "?invite=" + ex.Str("code")})
}
code := randomHex(8)
if _, err := c.DB.Exec("INSERT INTO invite_links (user_id, code) VALUES (?, ?)", uid, code); err != nil {
return err
}
return c.OK(M{"success": true, "code": code, "url": "?invite=" + code})
}
func checkInviteDataStatus(c *httpapi.Ctx) error {
uid := c.UID()
cp, _ := c.DB.QueryOne("SELECT c.id, c.data_deleted, c.status FROM couples c WHERE (c.user1_id = ? OR c.user2_id = ?) AND c.status = 'unbound' AND c.data_deleted = 0 LIMIT 1", uid, uid)
if cp != nil {
return c.OK(M{"has_pending_data": true, "msg": "请等待系统删除之前的情侣数据后再进行绑定操作"})
}
return c.OK(M{"has_pending_data": false})
}
func checkInviteCode(c *httpapi.Ctx) error {
code := strings.TrimSpace(c.Q("code"))
if code == "" {
return c.Err(400, "缺少邀请码")
}
link, err := c.DB.QueryOne("SELECT il.*, u.nickname, u.avatar FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", code)
if err != nil {
return err
}
if link == nil {
return c.Err(404, "邀请链接无效或已使用")
}
// PHP 中 $link['inviter_couple_id'] ?? $link['couple_id'] 两个键都不存在,判断恒为假,此处保持一致
return c.OK(M{"success": true, "inviter": M{"nickname": link["nickname"], "avatar": link["avatar"]}})
}
func inviteByPhone(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
target := c.InTrim("username")
if !c.InHas("username") {
target = c.InTrim("phone")
}
message := c.InTrim("message")
if target == "" {
return c.Err(400, "请输入对方用户名")
}
if pass, msg := c.App.Wechat.CheckText(message, userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
me, _ := c.DB.QueryOne("SELECT username, couple_id FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
if me != nil && me.Str("username") == target {
return c.Err(400, "不能邀请自己")
}
t, _ := c.DB.QueryOne("SELECT id, username, couple_id FROM users WHERE username = ?", target)
if t == nil {
return c.Err(400, "该用户不存在")
}
if t.Int("couple_id") != 0 {
return c.Err(400, "对方已绑定情侣")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil {
return c.Err(400, "已发送过邀请,等待对方回应")
}
if _, err := c.DB.Exec("INSERT INTO invitations (from_user_id, to_user_id, to_phone, message, type) VALUES (?, ?, ?, ?, 'username')", uid, t.Int("id"), target, message); err != nil {
return err
}
return c.OK(M{"success": true, "msg": "邀请已发送"})
}
func invitePartner(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
targetID := c.InTrim("target_user_id")
message := c.InTrim("message")
if targetID == "" {
return c.Err(400, "请输入对方ID")
}
if !targetIDRe.MatchString(targetID) {
return c.Err(400, "ID格式不正确,请输入6位数字ID")
}
me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
if me != nil && me.Str("user_id") == targetID {
return c.Err(400, "不能绑定自己")
}
if ex, _ := c.DB.QueryOne("SELECT c.id FROM couples c WHERE (c.user1_id = ? OR c.user2_id = ?) AND c.status = 'unbound' AND c.data_deleted = 0 LIMIT 1", uid, uid); ex != nil {
return c.Err(400, "请等待系统删除之前的情侣数据后再进行绑定操作")
}
t, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, nickname, gender FROM users WHERE user_id = ?", targetID)
if t == nil {
return c.Err(400, "该ID不存在,请确认对方ID是否正确")
}
if t.Int("couple_id") != 0 {
return c.Err(400, "对方已绑定情侣")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'", uid, t.Int("id")); ex != nil {
return c.Err(400, "已发送过邀请,等待对方回应")
}
id, err := c.DB.Insert("INSERT INTO invitations (from_user_id, to_user_id, message, type) VALUES (?, ?, ?, 'id')", uid, t.Int("id"), message)
if err != nil {
return err
}
return c.OK(M{"success": true, "invitation_id": fmt.Sprint(id), "msg": "邀请已发送,等待对方同意"})
}
func myInvitations(c *httpapi.Ctx) error {
uid := c.UID()
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
var coupleID int64
if user != nil {
coupleID = user.Int("couple_id")
}
received, err := c.DB.Query("SELECT i.*, u.username as from_username, u.nickname as from_nickname, u.avatar as from_avatar FROM invitations i JOIN users u ON i.from_user_id = u.id WHERE i.to_user_id = ? ORDER BY i.created_at DESC", uid)
if err != nil {
return err
}
sent, err := c.DB.Query("SELECT i.*, u.username as to_username, u.nickname as to_nickname, u.avatar as to_avatar FROM invitations i JOIN users u ON i.to_user_id = u.id WHERE i.from_user_id = ? ORDER BY i.created_at DESC", uid)
if err != nil {
return err
}
links, err := c.DB.Query("SELECT * FROM invite_links WHERE user_id = ? ORDER BY created_at DESC", uid)
if err != nil {
return err
}
return c.OK(M{"received": rows(received), "sent": rows(sent), "links": rows(links), "couple_id": coupleID})
}
// bindCouple 创建情侣关系(事务):couples 记录、空间 ID、双方 couple_id;返回 coupleId
func bindCouple(c *httpapi.Ctx, user1, user2 int64, extra func(tx *store.Tx) error) (int64, error) {
tx, err := c.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", user1, user2)
if err != nil {
return 0, err
}
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", generateCoupleSpaceId(coupleID), coupleID); err != nil {
return 0, err
}
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, user1, user2); err != nil {
return 0, err
}
if extra != nil {
if err := extra(tx); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
ensureUserDirs(user1)
ensureUserDirs(user2)
return coupleID, nil
}
func respondInvitation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
invID := c.InInt("invitation_id")
accept := c.InBool("accept")
inv, err := c.DB.QueryOne("SELECT * FROM invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", invID, uid)
if err != nil {
return err
}
if inv == nil {
return c.Err(404, "邀请不存在或已处理")
}
fromID := inv.Int("from_user_id")
rs, _ := c.DB.Query("SELECT couple_id FROM users WHERE id IN (?, ?)", uid, fromID)
for _, r := range rs {
if r.Int("couple_id") != 0 {
return c.Err(400, "已绑定情侣,无法接受")
}
}
if accept {
coupleID, err := bindCouple(c, fromID, uid, func(tx *store.Tx) error {
_, err := tx.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", invID)
return err
})
if err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, uid)
return c.OK(M{"success": true, "couple_id": fmt.Sprint(coupleID)})
}
c.DB.Exec("UPDATE invitations SET status = 'rejected', responded_at = CURRENT_TIMESTAMP WHERE id = ?", invID)
return c.OK(M{"success": true, "msg": "已拒绝邀请"})
}
func acceptShareInvite(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
inviterDisplayID := c.InTrim("inviter_id")
if inviterDisplayID == "" {
return c.Err(400, "缺少邀请人ID")
}
me, _ := c.DB.QueryOne("SELECT user_id, couple_id, gender, nickname FROM users WHERE id = ?", uid)
if me != nil && me.Int("couple_id") != 0 {
return c.Err(400, "您已绑定情侣")
}
inviter, _ := c.DB.QueryOne("SELECT id, user_id, couple_id, gender, nickname FROM users WHERE user_id = ?", inviterDisplayID)
if inviter == nil {
return c.Err(400, "邀请人不存在")
}
if inviter.Int("couple_id") != 0 {
return c.Err(400, "邀请人已绑定情侣")
}
if me != nil && me.Str("user_id") == inviter.Str("user_id") {
return c.Err(400, "不能绑定自己")
}
if me != nil && me.Str("gender") != "" && inviter.Str("gender") != "" && me.Str("gender") == inviter.Str("gender") {
return c.Err(400, "双方性别一致,仅支持异性绑定")
}
inviterID := inviter.Int("id")
existing, _ := c.DB.QueryOne("SELECT id FROM invitations WHERE ((from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)) AND status = 'pending'", uid, inviterID, inviterID, uid)
if existing != nil {
c.DB.Exec("UPDATE invitations SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ?", existing.Int("id"))
}
coupleID, err := bindCouple(c, uid, inviterID, func(tx *store.Tx) error {
if existing == nil {
_, err := tx.Exec("INSERT INTO invitations (from_user_id, to_user_id, status, responded_at, type) VALUES (?, ?, 'accepted', CURRENT_TIMESTAMP, 'share')", inviterID, uid)
return err
}
return nil
})
if err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, uid)
return c.OK(M{"success": true, "couple_id": fmt.Sprint(coupleID), "partner_nickname": inviter["nickname"]})
}
func coupleInfo(c *httpapi.Ctx) error {
cid := c.CoupleID()
uid := c.UID()
if cid == 0 {
user, _ := c.DB.QueryOne("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, created_at FROM users WHERE id = ?", uid)
regDate := today()
if user != nil {
ca := user.Str("created_at")
if len(ca) >= 10 {
regDate = ca[:10]
} else {
regDate = ca
}
}
var days int64
if regDate != "" {
t, err := parseDate(regDate)
if err != nil {
return err
}
days = diffDays(time.Now(), t)
}
var p1 any
if user != nil {
p1 = user
}
return c.OK(M{"not_bound": true, "partner1": p1, "partner2": nil, "love_start_date": regDate, "days_together": days})
}
info, err := c.DB.QueryOne("SELECT * FROM couples WHERE id = ?", cid)
if err != nil {
return err
}
if info == nil {
info = store.Row{}
}
partners, err := c.DB.Query("SELECT id, user_id, username, nickname, avatar, gender, age, birthday, region, location, lat, lng FROM users WHERE couple_id = ? ORDER BY id ASC", cid)
if err != nil {
return err
}
info["partner1"] = nil
info["partner2"] = nil
if len(partners) > 0 {
info["partner1"] = partners[0]
}
if len(partners) > 1 {
info["partner2"] = partners[1]
}
info["distance"] = nil
if len(partners) >= 2 {
lat1, lng1 := store.ToFloat(partners[0]["lat"]), store.ToFloat(partners[0]["lng"])
lat2, lng2 := store.ToFloat(partners[1]["lat"]), store.ToFloat(partners[1]["lng"])
if lat1 != 0 && lng1 != 0 && lat2 != 0 && lng2 != 0 {
info["distance"] = geo.Haversine(lat1, lng1, lat2, lng2)
}
}
var partner store.Row
for _, p := range partners {
if p.Int("id") != uid {
partner = p
}
}
if partner != nil && partner.Str("birthday") != "" {
bday, err := parseDate(partner.Str("birthday"))
if err != nil {
return err
}
next, daysLeft, ageNext := birthdayCountdown(time.Now(), bday, partner.Int("age"))
info["partner_birthday_countdown"] = M{
"birthday": partner["birthday"],
"next_date": next.Format("2006-01-02"),
"days_left": daysLeft,
"age_next": ageNext,
}
}
if store.Truthy(info["love_start_date"]) {
start, err := parseDate(info.Str("love_start_date"))
if err != nil {
return err
}
info["days_together"] = diffDays(start, time.Now())
} else {
info["days_together"] = 0
}
return c.OK(info)
}
func updateCoupleInfo(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := c.CoupleID()
if cid == 0 {
return c.Err(400, "请先绑定情侣")
}
uid := c.UID()
if pass, msg := c.App.Wechat.CheckText(c.InTrim("love_story"), userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
if _, err := c.DB.Exec("UPDATE couples SET love_start_date = ?, love_story = ? WHERE id = ?", c.InStr("love_start_date"), c.InStr("love_story"), cid); err != nil {
return err
}
if store.Truthy(c.InStr("love_start_date")) {
logActivity(c.DB, cid, uid, "couple_edit", "设置了恋爱纪念日", "")
}
return c.OK(M{"success": true})
}
func userCoupleID(c *httpapi.Ctx, uid int64) int64 {
u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if u == nil {
return 0
}
return u.Int("couple_id")
}
func unbindRequest(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
cid := userCoupleID(c, uid)
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
existing, _ := c.DB.QueryOne("SELECT id, status, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid)
if existing != nil {
if existing.Str("status") == "pending" {
return c.Err(400, "已有待处理的解绑请求,请等待对方回应")
}
if existing.Str("status") == "agreed" {
softUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "对方已同意解绑,情侣关系已解除,所有数据已保留"})
}
return nil
}
if _, err := c.DB.Exec("INSERT INTO unbind_requests (couple_id, requester_id, status, created_at) VALUES (?, ?, 'pending', NOW())", cid, uid); err != nil {
return err
}
logActivity(c.DB, cid, uid, "unbind_request", "发起了解除情侣绑定请求", "")
return c.OK(M{"success": true, "msg": "解绑请求已发送,等待对方确认(72小时内有效)"})
}
func unbindRespond(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
// $input = json_decode(...) ?: $_POST
agree := c.InBool("agree")
if len(c.Input()) == 0 {
agree = store.Truthy(c.Form("agree"))
}
cid := userCoupleID(c, uid)
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
req, _ := c.DB.QueryOne("SELECT id, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status = 'pending' AND requester_id != ? ORDER BY id DESC LIMIT 1", cid, uid)
if req == nil {
return c.Err(400, "没有待处理的解绑请求")
}
if created, err := parseDate(req.Str("created_at")); err == nil && time.Since(created) > 72*time.Hour {
c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id"))
return c.Err(400, "解绑请求已超过72小时,已自动取消")
}
if agree {
c.DB.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE id = ?", req.Int("id"))
logActivity(c.DB, cid, uid, "unbind_agreed", "同意了解除情侣绑定请求", "")
softUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已同意解绑,情侣关系已解除,所有数据已保留"})
}
c.DB.Exec("UPDATE unbind_requests SET status = 'rejected', responded_at = NOW() WHERE id = ?", req.Int("id"))
logActivity(c.DB, cid, uid, "unbind_rejected", "拒绝了解除情侣绑定请求", "")
return c.OK(M{"success": true, "msg": "已拒绝解绑请求"})
}
func unbindCancel(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
cid := userCoupleID(c, uid)
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
req, _ := c.DB.QueryOne("SELECT id, status FROM unbind_requests WHERE couple_id = ? AND requester_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid, uid)
if req == nil {
return c.Err(400, "没有可撤销的解绑请求")
}
c.DB.Exec("UPDATE unbind_requests SET status = 'cancelled', responded_at = NOW() WHERE id = ?", req.Int("id"))
logActivity(c.DB, cid, uid, "unbind_cancelled", "撤销了解除情侣绑定请求", "")
return c.OK(M{"success": true, "msg": "已撤销解绑请求"})
}
func unbindStatus(c *httpapi.Ctx) error {
uid := c.UID()
// $cid 来源不同类型不同:来自 users 行为字符串;来自 unbind_requests 反查时经过 intval 为整数
var cidVal any
u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
cid := int64(0)
if u != nil && u.Int("couple_id") != 0 {
cid, cidVal = u.Int("couple_id"), u["couple_id"]
}
if cid == 0 {
found, _ := c.DB.QueryOne("SELECT ur.couple_id FROM unbind_requests ur WHERE ur.requester_id = ? OR ur.id IN (SELECT ur2.id FROM unbind_requests ur2 JOIN couples c ON ur2.couple_id = c.id WHERE (c.user1_id = ? OR c.user2_id = ?)) ORDER BY ur.id DESC LIMIT 1", uid, uid, uid)
if found != nil {
cid, cidVal = found.Int("couple_id"), found.Int("couple_id")
}
}
if cid == 0 {
return c.OK(M{"status": "none", "msg": "未绑定情侣"})
}
req, err := c.DB.QueryOne("SELECT id, requester_id, status, created_at, responded_at FROM unbind_requests WHERE couple_id = ? ORDER BY id DESC LIMIT 1", cid)
if err != nil {
return err
}
dataDeleted := int64(0)
if cd, _ := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid); cd != nil {
dataDeleted = cd.Int("data_deleted")
}
if req == nil {
return c.OK(M{"status": "none", "msg": "无解绑请求", "couple_id": cidVal, "data_deleted": dataDeleted})
}
if req.Str("status") == "pending" {
created, _ := parseDate(req.Str("created_at"))
elapsed := int64(time.Since(created).Seconds())
remaining := int64(72*3600) - elapsed
if remaining < 0 {
remaining = 0
}
if elapsed > 72*3600 {
c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id"))
req["status"] = "expired"
} else {
req["remaining_seconds"] = remaining
}
}
req["is_requester"] = req.Int("requester_id") == uid
requester, _ := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", req.Int("requester_id"))
name := "未知"
if requester != nil {
name = requester.Str("nickname")
if name == "" {
name = requester.Str("username")
}
}
req["requester_name"] = name
req["couple_id"] = cid
req["data_deleted"] = dataDeleted
return c.OK(req)
}
func unbindCouple(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
cid := userCoupleID(c, uid)
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
if ag, _ := c.DB.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); ag == nil {
return c.Err(400, "没有已确认的解绑请求,请先发起解绑")
}
softUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已解除绑定"})
}
+30
View File
@@ -0,0 +1,30 @@
package handler
import (
"testing"
"time"
)
func TestBirthdayCountdown(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
tests := []struct {
name string
now time.Time
birthday time.Time
wantDate string
wantDays int64
wantAge int64
}{
{"birthday today", time.Date(2026, 9, 19, 18, 30, 0, 0, loc), time.Date(2000, 9, 19, 0, 0, 0, 0, loc), "2026-09-19", 0, 27},
{"birthday tomorrow", time.Date(2026, 9, 19, 23, 59, 0, 0, loc), time.Date(2000, 9, 20, 0, 0, 0, 0, loc), "2026-09-20", 1, 27},
{"birthday passed", time.Date(2026, 9, 19, 10, 0, 0, 0, loc), time.Date(2000, 1, 2, 0, 0, 0, 0, loc), "2027-01-02", 105, 27},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next, days, age := birthdayCountdown(tt.now, tt.birthday, 26)
if got := next.Format("2006-01-02"); got != tt.wantDate || days != tt.wantDays || age != tt.wantAge {
t.Fatalf("got date=%s days=%d age=%d; want date=%s days=%d age=%d", got, days, age, tt.wantDate, tt.wantDays, tt.wantAge)
}
})
}
}
+325
View File
@@ -0,0 +1,325 @@
package handler
import (
"fmt"
"regexp"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义
const mediaBase = "https://past.sundynix.cn/backend/"
var tagRe = regexp.MustCompile(`<[^>]*>`)
// stripTags 近似 PHP strip_tags
func stripTags(s string) string { return tagRe.ReplaceAllString(s, "") }
// mbSubstr 对应 mb_substr($s, 0, n)
func mbSubstr(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// avatarURL 对应评论里的 avatar_url 拼接
func avatarURL(avatar string) string {
if avatar == "" {
return ""
}
if strings.HasPrefix(avatar, "http") {
return avatar
}
return mediaBase + avatar
}
// diaryDateOrFallback 对应 PHP !empty($input['diary_date']) 的日期默认逻辑。
// 缺失、null、空字符串和纯空白都不允许进入 diary_date NOT NULL 列。
func diaryDateOrFallback(value any, fallback string) string {
if date := strings.TrimSpace(store.ToStr(value)); date != "" {
return date
}
return fallback
}
func registerDiary(r *httpapi.Router) {
r.Handle("diary_list", diaryList)
r.Handle("diary_add", diaryAdd)
r.Handle("diary_update", diaryUpdate)
r.Handle("diary_delete", diaryDelete)
r.Handle("diary_detail", diaryDetail)
r.Handle("diary_comment_list", diaryCommentList)
r.Handle("diary_comment_add", diaryCommentAdd)
r.Handle("diary_comment_delete", diaryCommentDelete)
}
func diaryList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
demo := demoData("diary")
return c.OK(M{"items": demo, "total": len(demo)})
}
page := c.QInt("page")
if !c.QHas("page") {
page = 1
}
if page < 1 {
page = 1
}
pageSize := int64(10)
if c.QHas("page_size") {
pageSize = c.QInt("page_size")
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 50 {
pageSize = 50
}
offset := (page - 1) * pageSize
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diaries WHERE created_by IN (?, ?)", ids[0], ids[1])
if err != nil {
return err
}
uid := c.UID()
items, err := c.DB.Query(`SELECT d.*,
(SELECT COUNT(*) FROM diary_comments WHERE diary_id = d.id) as comment_count,
(SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id) as like_count,
(SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id) as favorite_count,
(SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id AND user_id = ?) > 0 as is_liked,
(SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id AND user_id = ?) > 0 as is_favorited
FROM diaries d WHERE d.created_by IN (?, ?) ORDER BY d.diary_date DESC, d.created_at DESC LIMIT ? OFFSET ?`,
uid, uid, ids[0], ids[1], pageSize, offset)
if err != nil {
return err
}
for _, it := range items {
it["images"] = jsonDecodeList(it["images"])
it["is_liked"] = store.Truthy(it["is_liked"])
it["is_favorited"] = store.Truthy(it["is_favorited"])
}
return c.OK(M{"items": rows(items), "total": cnt["cnt"], "page": page, "page_size": pageSize})
}
func diaryAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
diaryDate := diaryDateOrFallback(c.In("diary_date"), today())
images := inOr(c, "images", []any{})
title := inOr(c, "title", "")
content := inOr(c, "content", "")
titleS, contentS := store.ToStr(title), store.ToStr(content)
region := c.InTrim("region")
lat, lng := c.InFloat("lat"), c.InFloat("lng")
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
if err := checkTextOr400(c, strings.TrimSpace(titleS+" "+contentS)); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid)
if err != nil {
return err
}
imgCount := 0
if l, ok := images.([]any); ok {
imgCount = len(l)
}
desc := "发表了日记"
if store.Truthy(titleS) {
desc += "「" + titleS + "」"
}
if imgCount > 0 {
desc += fmt.Sprintf(",上传了%d张图片", imgCount)
}
actID := logActivity(c.DB, cid, uid, "diary_create", desc, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
displayTitle := "一篇新日记"
if store.Truthy(titleS) {
displayTitle = titleS
}
displayTitle = mbTruncate(displayTitle, 20)
displayContent := "TA写了一些心里话"
if store.Truthy(contentS) {
displayContent = mbSubstr(stripTags(contentS), 50)
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": displayTitle, "thing2": displayContent, "date3": today(), "thing4": "快去看看吧 💕",
}, "pages/diary/diary", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func diaryUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
images := inOr(c, "images", []any{})
// 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期;
// 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。
diaryDate := strings.TrimSpace(c.InStr("diary_date"))
dateExpr := "COALESCE(NULLIF(?, ''), NULLIF(diary_date, ''), ?)"
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
if c.InHas("lat") {
_, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), c.In("id"), ids[0], ids[1])
} else {
_, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.In("id"), ids[0], ids[1])
}
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func diaryDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func countOf(c *httpapi.Ctx, sql string, args ...any) int64 {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return 0
}
return r.Int("cnt")
}
func diaryDetail(c *httpapi.Ctx) error {
id := c.QInt("id")
if id == 0 {
return c.Err(400, "缺少日记ID")
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
uid := c.UID()
item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "日记不存在")
}
item["images"] = jsonDecodeList(item["images"])
item["comment_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_comments WHERE diary_id = ?", id)
if _, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id); err != nil {
return err
}
item["like_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id)
item["favorite_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ?", id)
item["is_liked"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ? AND user_id = ?", id, uid) > 0
item["is_favorited"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ? AND user_id = ?", id, uid) > 0
return c.OK(item)
}
func diaryCommentList(c *httpapi.Ctx) error {
diaryID := c.QInt("diary_id")
if diaryID == 0 {
return c.Err(400, "缺少日记ID")
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at ASC", diaryID, ids[0], ids[1])
if err != nil {
return err
}
for _, cm := range comments {
cm["avatar_url"] = avatarURL(cm.Str("avatar"))
}
return c.OK(rows(comments))
}
func diaryCommentAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if uid == 0 {
return c.Err(401, "请先登录")
}
diaryID := c.InInt("diary_id")
content := c.InTrim("content")
if diaryID == 0 {
return c.Err(400, "缺少日记ID")
}
if !store.Truthy(content) {
return c.Err(400, "请输入评论内容")
}
if err := checkTextOr400(c, content); err != nil {
return err
}
if d, _ := c.DB.QueryOne("SELECT id FROM diaries WHERE id = ? AND couple_id = ?", diaryID, cid); d == nil {
return c.Err(404, "日记不存在")
}
parentID := c.InInt("parent_id")
var replyToUserID any
if c.InHas("reply_to_user_id") {
replyToUserID = c.InInt("reply_to_user_id")
}
commentID, err := c.DB.Insert("INSERT INTO diary_comments (diary_id, couple_id, user_id, content, parent_id, reply_to_user_id, reply_to_username) VALUES (?, ?, ?, ?, ?, ?, ?)",
diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username"))
if err != nil {
return err
}
comment, _ := c.DB.QueryOne("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.id = ?", commentID)
var out any = false
if comment != nil {
comment["avatar_url"] = avatarURL(comment.Str("avatar"))
out = comment
}
return c.OK(M{"success": true, "comment": out})
}
func diaryCommentDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
if uid == 0 {
return c.Err(401, "请先登录")
}
commentID := c.InInt("id")
if commentID == 0 {
return c.Err(400, "缺少评论ID")
}
if _, err := c.DB.Exec("DELETE FROM diary_comments WHERE id = ? AND user_id = ?", commentID, uid); err != nil {
return err
}
return c.OK(M{"success": true})
}
+24
View File
@@ -0,0 +1,24 @@
package handler
import "testing"
func TestDiaryDateOrFallback(t *testing.T) {
tests := []struct {
name string
value any
fallback string
want string
}{
{"missing", nil, "2026-09-19", "2026-09-19"},
{"empty", "", "2026-09-19", "2026-09-19"},
{"whitespace", " ", "2026-09-19", "2026-09-19"},
{"provided", " 2026-09-18 ", "2026-09-19", "2026-09-18"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := diaryDateOrFallback(tt.value, tt.fallback); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+15
View File
@@ -0,0 +1,15 @@
package handler
import "testing"
// 期望值来自 PHP 8.2: echo json_encode(["uploads/x.jpg","中文😊","a\"b\\c\n<>&"]);
func TestPhpEscapeJSON(t *testing.T) {
got := jsonEncode([]any{"uploads/x.jpg", "中文😊", "a\"b\\c\n<>&"})
want := "[\"uploads\\/x.jpg\",\"\\u4e2d\\u6587\\ud83d\\ude0a\",\"a\\\"b\\\\c\\n<>&\"]"
if got != want {
t.Fatalf("got %s want %s", got, want)
}
if jsonDecodeList(got).([]any)[1] != "中文😊" {
t.Fatalf("round-trip failed")
}
}
+294
View File
@@ -0,0 +1,294 @@
package handler
import (
"math"
"strings"
"time"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
// ========== 通用上传 / 背景图 ==========
func registerBg(r *httpapi.Router) {
r.Handle("upload", uploadGeneric)
r.Handle("get_bg_config", getBgConfig)
r.Handle("set_bg_image", setBgImage)
r.Handle("delete_bg_image", deleteBgImage)
}
// uploadResult 统一处理 handleUpload 结果的响应(成功/违规/失败)
func uploadResult(c *httpapi.Ctx, res upload.Result, onSuccess func(path string) error) error {
if res.Path != "" {
return onSuccess(res.Path)
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
return c.ErrCode(400, "上传失败: "+orDefault(res.Error, "未知错误"), upload.ErrHandleFailed)
}
func uploadGeneric(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
subDir := "photos"
if c.FormHas("sub_dir") {
subDir = c.Form("sub_dir")
}
uid := c.UID()
if uid == 0 {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
}
prefix := upload.UserFilePath(uid, subDir)
var res *upload.Result
for _, f := range []string{"file", "avatar", "photo", "image"} {
if fh := c.File(f); fh != nil {
r := up.Handle(fh, "", prefix)
res = &r
break
}
}
if res == nil {
// 兼容某些 Android 端:原始请求体
if raw := c.Body(); len(raw) > 100 {
r := up.HandleBytes(raw, "upload_"+time.Now().Format("20060102150405")+".jpg", "", prefix)
res = &r
}
}
if res == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
return uploadResult(c, *res, func(path string) error {
return c.OK(M{"success": true, "path": path})
})
}
func settingValue(c *httpapi.Ctx, key string) (string, bool) {
row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key)
if row == nil {
return "", false
}
return row.Str("value"), true
}
func setSetting(c *httpapi.Ctx, key string, value any) error {
_, err := c.DB.Exec("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?", key, value, value)
return err
}
func bgKeys(cid int64) (string, string) {
if cid != 0 {
return "bg_" + store.ToStr(cid), "bgver_" + store.ToStr(cid)
}
return "bg_global", "bgver_global"
}
func getBgConfig(c *httpapi.Ctx) error {
bgKey, verKey := bgKeys(c.CoupleID())
img, _ := settingValue(c, bgKey)
ver, _ := settingValue(c, verKey)
return c.OK(M{"image": img, "version": store.ToInt(ver)})
}
func setBgImage(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
cid := c.CoupleID()
if cid == 0 {
return c.ErrCode(400, "请先绑定情侣", upload.ErrNotBound)
}
couplePath := upload.CouplePath(coupleSpaceID(c, cid), "covers")
bgKey, verKey := bgKeys(cid)
nextVersion := func() int64 {
if old, ok := settingValue(c, verKey); ok {
return store.ToInt(old) + 1
}
return 1
}
saveBg := func(path string, version int64) error {
if err := setSetting(c, bgKey, path); err != nil {
return err
}
if err := setSetting(c, verKey, version); err != nil {
return err
}
return c.OK(M{"success": true, "path": path, "version": version})
}
fh := c.File("bg")
if fh == nil {
fh = c.File("file")
}
if fh != nil {
version := nextVersion()
return uploadResult(c, up.Handle(fh, "", couplePath), func(path string) error { return saveBg(path, version) })
}
if raw := c.Body(); len(raw) > 100 {
res := up.SaveRaw(raw, "bg_", "jpg", "", couplePath)
if res.Path != "" {
return saveBg(res.Path, nextVersion())
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
return c.ErrCode(400, "原始数据保存失败", upload.ErrRawBodySave)
}
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
func deleteBgImage(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
bgKey, verKey := bgKeys(cid)
if v, ok := settingValue(c, bgKey); ok && v != "" {
up.Remove(v)
}
if _, err := c.DB.Exec("DELETE FROM settings WHERE `key` = ? OR `key` = ?", bgKey, verKey); err != nil {
return err
}
return c.OK(M{"success": true})
}
// ========== 站点公开 ==========
func registerSite(r *httpapi.Router) {
r.Handle("site_info", siteInfo)
r.Handle("site_stats", siteStats)
}
func siteInfo(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT `key`, `value` FROM settings")
if err != nil {
return err
}
if len(rs) == 0 {
return c.OK([]any{}) // PHP 空数组 → []
}
out := M{}
for _, r := range rs {
out[r.Str("key")] = r["value"]
}
return c.OK(out)
}
func siteStats(c *httpapi.Ctx) error {
raw := func(t string) any { return countRaw(c, "cnt", "SELECT COUNT(*) as cnt FROM "+t) }
cnt := func(t string) int64 { return countOf(c, "SELECT COUNT(*) as cnt FROM "+t) }
couples, users := raw("couples"), raw("users")
memories := cnt("timeline") + cnt("photos") + cnt("diaries")
var days any = 0
if e, _ := c.DB.QueryOne("SELECT MIN(created_at) as d FROM users"); e != nil && e.Str("d") != "" {
if t, err := parseDate(e.Str("d")); err == nil {
d := math.Floor(time.Since(t).Seconds() / 86400)
if d < 1 {
d = 1
}
days = d
}
}
return c.OK(M{"couples": couples, "users": users, "memories": memories, "days": days})
}
// ========== 操作记录 / 订阅 ==========
func registerActivity(r *httpapi.Router) {
r.Handle("activity_log_list", activityLogList)
}
func registerSubscribe(r *httpapi.Router) {
r.Handle("subscribe_template", subscribeTemplate)
r.Handle("unsubscribe_template", unsubscribeTemplate)
r.Handle("my_subscribe_templates", mySubscribeTemplates)
}
// pageParams 对应 max(1, intval(page)) 与 min(maxSize, max(1, intval(page_size)))
func pageParams(c *httpapi.Ctx, defSize, maxSize int64) (page, pageSize, offset int64) {
page = 1
if c.QHas("page") {
page = c.QInt("page")
}
if page < 1 {
page = 1
}
pageSize = defSize
if c.QHas("page_size") {
pageSize = c.QInt("page_size")
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > maxSize {
pageSize = maxSize
}
return page, pageSize, (page - 1) * pageSize
}
func activityLogList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK([]any{})
}
page, pageSize, offset := pageParams(c, 6, 100)
totalRaw := countRaw(c, "cnt", "SELECT COUNT(*) as cnt FROM activity_log WHERE user_id IN (?, ?)", ids[0], ids[1])
total := store.ToInt(totalRaw)
items, err := c.DB.Query("SELECT al.*, u.nickname, u.avatar, u.gender FROM activity_log al JOIN users u ON al.user_id = u.id WHERE al.user_id IN (?, ?) ORDER BY al.created_at DESC LIMIT ? OFFSET ?", ids[0], ids[1], pageSize, offset)
if err != nil {
return err
}
return c.OK(M{"items": rows(items), "total": totalRaw, "page": page, "page_size": pageSize, "total_pages": math.Ceil(float64(total) / float64(pageSize))})
}
func subscribeTemplate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
templateType, templateID := c.InTrim("template_type"), c.InTrim("template_id")
if !store.Truthy(templateType) || !store.Truthy(templateID) {
return c.Err(400, "缺少参数")
}
if _, ok := c.App.Wechat.Templates()[templateType]; !ok {
return c.Err(400, "不支持的模板类型")
}
if _, err := c.DB.Exec("INSERT INTO subscribe_templates (user_id, template_id, template_type) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE template_id = VALUES(template_id)", uid, templateID, templateType); err != nil {
return err
}
return c.OK(M{"success": true})
}
func unsubscribeTemplate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
templateType := c.InTrim("template_type")
if !store.Truthy(templateType) {
return c.Err(400, "缺少参数")
}
if _, err := c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", c.UID(), templateType); err != nil {
return err
}
return c.OK(M{"success": true})
}
func mySubscribeTemplates(c *httpapi.Ctx) error {
rs, err := c.DB.Query("SELECT template_type, template_id FROM subscribe_templates WHERE user_id = ?", c.UID())
if err != nil {
return err
}
if len(rs) == 0 {
return c.OK(M{"templates": []any{}}) // PHP 空数组 → []
}
out := M{}
for _, r := range rs {
out[r.Str("template_type")] = r["template_id"]
}
return c.OK(M{"templates": out})
}
+558
View File
@@ -0,0 +1,558 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
func registerPeriod(r *httpapi.Router) {
r.Handle("period_list", periodList)
r.Handle("period_add", periodProtected(periodAdd))
r.Handle("period_update", periodProtected(periodUpdate))
r.Handle("period_delete", periodProtected(periodDelete))
r.Handle("period_visible_get", periodVisibleGet)
r.Handle("period_visible_set", periodVisibleSet)
r.Handle("period_source_select", periodSourceSelect)
r.Handle("period_daily_save", periodProtected(periodDailySave))
r.Handle("period_daily_get", periodProtected(periodDailyGet))
r.Handle("period_sex_add", periodProtected(periodSexAdd))
r.Handle("period_sex_delete", periodProtected(periodSexDelete))
r.Handle("period_sex_list", periodProtected(periodSexList))
}
func periodProtected(next httpapi.HandlerFunc) httpapi.HandlerFunc {
return func(c *httpapi.Ctx) error {
if err := requirePeriodAccess(c); err != nil {
return err
}
if required, _, _, err := periodSourceState(c); err != nil {
return err
} else if required {
return c.Err(409, "请先选择姨姨记录来源")
}
return next(c)
}
}
// cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0
func cidOrNil(c *httpapi.Ctx) any {
if cid := c.CoupleID(); cid != 0 {
return cid
}
return nil
}
// coupleCond 根据 cid(可能为 nil)构造 couple_id 过滤条件与参数
func coupleCond(cid any) (string, []any) {
if cid == nil {
return "couple_id IS NULL", nil
}
return "couple_id = ?", []any{cid}
}
// 每人独立控制自己是否向伴侣开放同一份姨姨数据。
func periodVisible(c *httpapi.Ctx, uid int64) int64 {
row, _ := c.DB.QueryOne("SELECT period_visible FROM users WHERE id = ?", uid)
if row == nil {
return 0
}
return row.Int("period_visible")
}
func emptyPeriod(visible int64) M {
return M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{}, "period_visible": visible, "can_access": false}
}
type periodAccess struct {
uid, cid, partnerID int64
visible, partnerVisible int64
}
func currentPeriodAccess(c *httpapi.Ctx) periodAccess {
uid := c.UID()
cid := c.CoupleID()
access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1}
if cid != 0 {
access.partnerVisible = 0
partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid)
if partner != nil {
access.partnerID = partner.Int("id")
access.partnerVisible = partner.Int("period_visible")
}
}
return access
}
func (a periodAccess) canAccess() bool {
return a.partnerVisible != 0
}
func requirePeriodAccess(c *httpapi.Ctx) error {
if !currentPeriodAccess(c).canAccess() {
return c.Err(403, "对方已关闭姨姨可见权限")
}
return nil
}
func periodList(c *httpapi.Ctx) error {
access := currentPeriodAccess(c)
if !access.canAccess() {
return c.OK(emptyPeriod(access.visible))
}
selectionRequired, me, partner, err := periodSourceState(c)
if err != nil {
return err
}
if selectionRequired {
myPreview, err := periodSourcePreview(c.DB, me)
if err != nil {
return err
}
partnerPreview, err := periodSourcePreview(c.DB, partner)
if err != nil {
return err
}
return c.OK(M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{},
"period_visible": access.visible, "can_access": true, "has_couple": true,
"selection_required": true, "my_user_id": me, "partner_user_id": partner,
"my_preview": myPreview, "partner_preview": partnerPreview})
}
// 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。
var where string
var args []any
if access.cid != 0 {
where = "couple_id = ?"
args = []any{access.cid}
} else {
where = "couple_id IS NULL AND user_id = ?"
args = []any{access.uid}
}
cycles, err := c.DB.Query("SELECT * FROM period_records WHERE "+where+" ORDER BY start_date DESC", args...)
if err != nil {
return err
}
daily, err := c.DB.Query("SELECT * FROM period_daily_records WHERE "+where+" ORDER BY record_date DESC", args...)
if err != nil {
return err
}
sex, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+where+" ORDER BY record_date DESC, record_time ASC", args...)
if err != nil {
return err
}
return c.OK(M{"cycles": rows(cycles), "daily": rows(daily), "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0})
}
func periodAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
if !store.Truthy(startDate) || !store.Truthy(endDate) {
return c.Err(400, "请选择日期")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
return err
}
cycleLen, periodLen := int64(28), int64(5)
if c.InHas("cycle_length") {
cycleLen = c.InInt("cycle_length")
}
if c.InHas("period_length") {
periodLen = c.InInt("period_length")
}
recID, err := c.DB.Insert("INSERT INTO period_records (couple_id, user_id, start_date, end_date, cycle_length, period_length, symptoms, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
cid, uid, startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""))
if err != nil {
return err
}
// 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日)
start, err := parseDate(startDate)
if err != nil {
return err
}
end, err := parseDate(endDate)
if err != nil {
return err
}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, d.Format("2006-01-02")); err != nil {
return err
}
}
actID, err := logActivityErr(c, cid, uid, "period_add", "记录了经期("+startDate+" ~ "+endDate+")")
if err != nil {
return err
}
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "经期记录有更新",
"thing2": fmt.Sprintf("TA记录了经期(%d天),记得多关心哦 🌸", periodLen),
"date3": startDate,
"thing4": "点击查看详情",
}, "pages/period/period", actID, uid)
}
}
// PHP 返回的是 logActivity 之后的 lastInsertId(activity_log 的 id);Go 返回真实记录 id
return c.OK(M{"success": true, "id": fmt.Sprint(recID)})
}
// logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。
// 未绑定情侣(coupleID 为 nil)时跳过日志:activity_log.couple_id 为 NOT NULL,未绑定时无从归属。
func logActivityErr(c *httpapi.Ctx, coupleID any, userID int64, actionType, description string) (int64, error) {
if coupleID == nil {
return 0, nil
}
return c.DB.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, "")
}
func periodUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少记录ID")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
return err
}
cycleLen, periodLen := int64(28), int64(5)
if c.InHas("cycle_length") {
cycleLen = c.InInt("cycle_length")
}
if c.InHas("period_length") {
periodLen = c.InInt("period_length")
}
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
start, err := parseDate(startDate)
if err != nil {
return c.Err(400, "开始日期无效")
}
end, err := parseDate(endDate)
if err != nil || end.Before(start) {
return c.Err(400, "结束日期无效")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
oldArgs := append([]any{id}, condArgs...)
old, err := tx.QueryOne("SELECT start_date, end_date FROM period_records WHERE id = ? AND "+cond+" FOR UPDATE", oldArgs...)
if err != nil {
return err
}
if old == nil {
return c.Err(404, "周期记录不存在")
}
args := []any{startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""), id}
args = append(args, condArgs...)
if _, err := tx.Exec("UPDATE period_records SET start_date = ?, end_date = ?, cycle_length = ?, period_length = ?, symptoms = ?, note = ? WHERE id = ? AND "+cond, args...); err != nil {
return err
}
// 同步日历标记:去掉旧区间中不再属于任何周期的日期,再标记新区间。
oldStart, err := parseDate(old.Str("start_date"))
if err != nil {
return err
}
oldEnd, err := parseDate(old.Str("end_date"))
if err != nil {
return err
}
for d := oldStart; !d.After(oldEnd); d = d.AddDate(0, 0, 1) {
if !d.Before(start) && !d.After(end) {
continue
}
date := d.Format("2006-01-02")
checkArgs := append(append([]any{}, condArgs...), date, date)
other, err := tx.QueryOne("SELECT id FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ? LIMIT 1", checkArgs...)
if err != nil {
return err
}
if other == nil {
clearArgs := append(append([]any{}, condArgs...), date)
if _, err := tx.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date = ?", clearArgs...); err != nil {
return err
}
}
}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
date := d.Format("2006-01-02")
if cid == nil {
// MySQL 的 UNIQUE 索引允许多个 NULL;未绑定记录必须按用户和日期查找。
existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, date)
if err != nil {
return err
}
if existing != nil {
if _, err := tx.Exec("UPDATE period_daily_records SET period = '1' WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", uid, date); err != nil {
return err
}
continue
}
}
if _, err := tx.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, date); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return c.OK(M{"success": true})
}
func periodDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
if id != 0 {
delArgs := append([]any{id}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err
}
} else if store.Truthy(startDate) && store.Truthy(endDate) {
delArgs := append(append([]any{}, condArgs...), startDate, endDate)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE "+cond+" AND start_date = ? AND end_date = ?", delArgs...); err != nil {
return err
}
}
if store.Truthy(startDate) && store.Truthy(endDate) {
updArgs := append(append([]any{}, condArgs...), startDate, endDate)
if _, err := c.DB.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date >= ? AND record_date <= ?", updArgs...); err != nil {
return err
}
}
if _, err := logActivityErr(c, cid, uid, "period_delete", "删除了经期周期记录"); err != nil {
return err
}
return c.OK(M{"success": true})
}
func periodVisibleGet(c *httpapi.Ctx) error {
access := currentPeriodAccess(c)
return c.OK(M{"period_visible": access.visible, "can_access": access.canAccess(), "has_couple": access.cid != 0})
}
func periodVisibleSet(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
visible := c.InInt("period_visible")
if visible != 0 && visible != 1 {
return c.Err(400, "可见权限只能为 0 或 1")
}
if _, err := c.DB.Exec("UPDATE users SET period_visible = ? WHERE id = ?", visible, c.UID()); err != nil {
return err
}
return c.OK(M{"success": true, "period_visible": visible})
}
func periodDailySave(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
recordDate := c.InTrim("record_date")
if !store.Truthy(recordDate) {
return c.Err(400, "请选择日期")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("mood")+" "+c.InStr("discharge")+" "+c.InStr("note"))); err != nil {
return err
}
// $input['temperature'] !== '' && !== null ? floatval(...) : null
numOrNil := func(key string) any {
v := c.In(key)
if v == nil || v == "" {
return nil
}
return store.ToFloat(v)
}
values := []any{store.ToStr(inOr(c, "period", "0")), store.ToStr(inOr(c, "sex", "0")),
inOr(c, "symptoms", ""), inOr(c, "mood", ""), inOr(c, "discharge", ""),
numOrNil("temperature"), numOrNil("weight"), inOr(c, "note", ""), inOr(c, "period_detail", "")}
if cid == nil {
// UNIQUE(record_date, couple_id) 对 NULL 不生效;按本人和日期更新已有行。
existing, err := c.DB.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, recordDate)
if err != nil {
return err
}
if existing != nil {
args := append(append([]any{}, values...), uid, recordDate)
if _, err := c.DB.Exec("UPDATE period_daily_records SET period = ?, sex = ?, symptoms = ?, mood = ?, discharge = ?, temperature = ?, weight = ?, note = ?, period_detail = ?, updated_at = NOW() WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", args...); err != nil {
return err
}
} else {
args := append([]any{nil, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", args...); err != nil {
return err
}
}
} else {
args := append([]any{cid, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE period = VALUES(period), sex = VALUES(sex), symptoms = VALUES(symptoms), mood = VALUES(mood), discharge = VALUES(discharge), temperature = VALUES(temperature), weight = VALUES(weight), note = VALUES(note), period_detail = VALUES(period_detail), updated_at = NOW()", args...); err != nil {
return err
}
}
// 若本次将月经标记设为无,检查所属周期是否还有月经记录
np := inOr(c, "period", "0")
if np == "0" || np == int64(0) || np == "" {
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
cycArgs := append(append([]any{}, condArgs...), recordDate, recordDate)
cycles, err := c.DB.Query("SELECT id, start_date, end_date FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ?", cycArgs...)
if err != nil {
return err
}
for _, cy := range cycles {
cntArgs := append(append([]any{}, condArgs...), cy["start_date"], cy["end_date"])
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM period_daily_records WHERE "+cond+" AND record_date >= ? AND record_date <= ? AND period > 0", cntArgs...)
if err != nil {
return err
}
if cnt != nil && cnt.Int("cnt") == 0 {
delArgs := append([]any{cy.Int("id")}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err
}
}
}
}
return c.OK(M{"success": true})
}
func periodDailyGet(c *httpapi.Ctx) error {
cid := cidOrNil(c)
uid := c.UID()
date := strings.TrimSpace(c.Q("record_date"))
if !store.Truthy(date) {
return c.Err(400, "请选择日期")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
args := append(append([]any{}, condArgs...), date)
rec, err := c.DB.QueryOne("SELECT * FROM period_daily_records WHERE "+cond+" AND record_date = ?", args...)
if err != nil {
return err
}
if rec == nil {
return c.OK([]any{})
}
return c.OK(rec)
}
func periodSexAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
recordDate := c.InTrim("record_date")
if !store.Truthy(recordDate) {
return c.Err(400, "请选择日期")
}
recordTime := c.InTrim("record_time")
measure := c.InTrim("measure")
measureCustom := c.InTrim("measure_custom")
if err := checkTextOr400(c, measureCustom); err != nil {
return err
}
var rt any
if store.Truthy(recordTime) {
rt = recordTime
}
recID, err := c.DB.Insert("INSERT INTO period_sex_records (couple_id, user_id, record_date, record_time, measure, measure_custom) VALUES (?, ?, ?, ?, ?, ?)",
cid, uid, recordDate, rt, measure, measureCustom)
if err != nil {
return err
}
desc := "记录了亲密行为"
if store.Truthy(recordTime) {
desc += "(" + recordDate + " " + recordTime + ")"
}
actID, err := logActivityErr(c, cid, uid, "period_sex_add", desc)
if err != nil {
return err
}
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情",
}, "pages/period/period", actID, uid)
}
}
return c.OK(M{"success": true, "id": fmt.Sprint(recID)})
}
func periodSexDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少记录ID")
}
if cid == nil {
if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id IS NULL AND user_id = ?", id, uid); err != nil {
return err
}
} else {
if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id = ?", id, cid); err != nil {
return err
}
}
return c.OK(M{"success": true})
}
func periodSexList(c *httpapi.Ctx) error {
cid := cidOrNil(c)
uid := c.UID()
date := strings.TrimSpace(c.Q("record_date"))
if !store.Truthy(date) {
return c.Err(400, "请选择日期")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
args := append(append([]any{}, condArgs...), date)
items, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+cond+" AND record_date = ? ORDER BY record_time ASC, id ASC", args...)
if err != nil {
return err
}
return c.OK(rows(items))
}
+147
View File
@@ -0,0 +1,147 @@
package handler
import (
"fmt"
"past/internal/httpapi"
"past/internal/store"
)
// 绑定前的姨姨记录可能双方都有。只允许一份成为情侣的当前记录;
// 在用户选择前保留两份,选择时在同一事务中删除另一份并迁入当前情侣空间。
func periodSourceState(c *httpapi.Ctx) (required bool, me, partner int64, err error) {
cid, uid := c.CoupleID(), c.UID()
if cid == 0 {
return false, uid, 0, nil
}
couple, err := c.DB.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ?", cid)
if err != nil || couple == nil {
return false, 0, 0, err
}
if couple.Int("user1_id") == uid {
partner = couple.Int("user2_id")
} else if couple.Int("user2_id") == uid {
partner = couple.Int("user1_id")
} else {
return false, 0, 0, fmt.Errorf("账号不属于当前情侣")
}
if couple.Int("period_source_user_id") != 0 {
return false, uid, partner, nil
}
myCount, err := periodSourceCount(c.DB, uid)
if err != nil {
return false, 0, 0, err
}
partnerCount, err := periodSourceCount(c.DB, partner)
if err != nil {
return false, 0, 0, err
}
if myCount > 0 && partnerCount > 0 {
return true, uid, partner, nil
}
if myCount > 0 {
err = selectPeriodSource(c, uid)
} else if partnerCount > 0 {
err = selectPeriodSource(c, partner)
}
return false, uid, partner, err
}
func periodSourceCount(db store.Querier, uid int64) (int64, error) {
row, err := db.QueryOne(`SELECT
(SELECT COUNT(*) FROM period_records WHERE user_id = ? AND couple_id IS NULL) +
(SELECT COUNT(*) FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL) +
(SELECT COUNT(*) FROM period_sex_records WHERE user_id = ? AND couple_id IS NULL) AS n`,
uid, uid, uid)
if err != nil {
return 0, err
}
return row.Int("n"), nil
}
func periodSourcePreview(db store.Querier, uid int64) (M, error) {
row, err := db.QueryOne("SELECT COUNT(*) AS cycles, MAX(start_date) AS latest FROM period_records WHERE user_id = ? AND couple_id IS NULL", uid)
if err != nil {
return nil, err
}
total, err := periodSourceCount(db, uid)
if err != nil {
return nil, err
}
return M{"cycles": row.Int("cycles"), "latest": row.Str("latest"), "items": total}, nil
}
func selectPeriodSource(c *httpapi.Ctx, keepUserID int64) error {
cid := c.CoupleID()
if cid == 0 {
return c.Err(400, "未绑定伴侣")
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
couple, err := tx.QueryOne("SELECT user1_id, user2_id, period_source_user_id FROM couples WHERE id = ? FOR UPDATE", cid)
if err != nil {
return err
}
if couple == nil || (keepUserID != couple.Int("user1_id") && keepUserID != couple.Int("user2_id")) {
return c.Err(400, "记录来源不是当前情侣成员")
}
if existing := couple.Int("period_source_user_id"); existing != 0 {
if existing == keepUserID {
return nil
}
return c.Err(409, "姨姨记录来源已经选定")
}
deleteUserID := couple.Int("user1_id")
if deleteUserID == keepUserID {
deleteUserID = couple.Int("user2_id")
}
for _, table := range []string{"period_records", "period_daily_records", "period_sex_records"} {
if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ? AND couple_id IS NULL", deleteUserID); err != nil {
return err
}
}
// daily 的旧 NULL 记录可能有重复日期,逐条迁入以避免情侣日期唯一索引冲突。
daily, err := tx.Query("SELECT id, record_date FROM period_daily_records WHERE user_id = ? AND couple_id IS NULL ORDER BY id DESC FOR UPDATE", keepUserID)
if err != nil {
return err
}
for _, record := range daily {
existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id = ? AND record_date = ? LIMIT 1", cid, record["record_date"])
if err != nil {
return err
}
if existing != nil {
if _, err := tx.Exec("DELETE FROM period_daily_records WHERE id = ?", record.Int("id")); err != nil {
return err
}
} else if _, err := tx.Exec("UPDATE period_daily_records SET couple_id = ? WHERE id = ?", cid, record.Int("id")); err != nil {
return err
}
}
for _, table := range []string{"period_records", "period_sex_records"} {
if _, err := tx.Exec("UPDATE "+table+" SET couple_id = ? WHERE user_id = ? AND couple_id IS NULL", cid, keepUserID); err != nil {
return err
}
}
if _, err := tx.Exec("UPDATE couples SET period_source_user_id = ? WHERE id = ?", keepUserID, cid); err != nil {
return err
}
return tx.Commit()
}
func periodSourceSelect(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
if err := requirePeriodAccess(c); err != nil {
return err
}
keepUserID := c.InInt("user_id")
if err := selectPeriodSource(c, keepUserID); err != nil {
return err
}
return c.OK(M{"success": true, "source_user_id": keepUserID})
}
+522
View File
@@ -0,0 +1,522 @@
package handler
import (
"fmt"
"math"
"time"
"past/internal/httpapi"
"past/internal/store"
)
func registerPet(r *httpapi.Router) {
r.Handle("pet_info", petInfo)
r.Handle("pet_adopt", petAdopt)
r.Handle("pet_daily_action", petDailyAction)
r.Handle("pet_daily_status", petDailyStatus)
r.Handle("pet_achievements", petAchievements)
r.Handle("pet_rename", petRename)
r.Handle("pet_equip", petEquip)
r.Handle("pet_dynamics", petDynamics)
}
type petAch struct{ Key, Name, Desc, Icon string }
// allPetAchievements 对应 getAllPetAchievements()(保持顺序)
var allPetAchievements = []petAch{
{"first_adopt", "初次相遇", "领养第一只宠物", "🐣"},
{"level_5", "成长之路", "宠物达到5级", "⭐"},
{"level_10", "小有成就", "宠物达到10级", "🌟"},
{"level_20", "资深铲屎官", "宠物达到20级", "👑"},
{"level_30", "终极陪伴", "宠物达到30级", "💎"},
{"stage_2", "少年初长成", "宠物进入少年期", "🐕"},
{"stage_3", "青春活力", "宠物进入青年期", "🦮"},
{"stage_4", "成熟稳重", "宠物进入成年期", "🐩"},
{"stage_5", "终极形态", "宠物进入终极期", "🦁"},
{"all_done_7", "一周全勤", "连续7天完成所有任务", "📅"},
{"all_done_30", "月度之星", "连续30天完成所有任务", "🏆"},
{"feed_100", "大胃王", "累计喂食100次", "🍖"},
{"walk_50", "散步达人", "累计散步50次", "🚶"},
{"bath_30", "爱干净", "累计洗澡30次", "🛁"},
{"play_50", "玩伴", "累计玩耍50次", "🎾"},
{"happiness_100", "快乐满分", "宠物心情达到100", "😊"},
{"rename_first", "取名大师", "第一次给宠物改名", "✏️"},
{"adopt_7days", "一周陪伴", "领养满7天", "📆"},
{"adopt_30days", "一月相伴", "领养满30天", "🗓️"},
{"adopt_100days", "百日纪念", "领养满100天", "💯"},
}
func petAchByKey(key string) petAch {
for _, a := range allPetAchievements {
if a.Key == key {
return a
}
}
return petAch{}
}
// consecutiveDoneDays 从按日期倒序的 all_done 记录统计连续天数
func consecutiveDoneDays(rows []store.Row) int64 {
var consecutive int64
var prev time.Time
for i, r := range rows {
cur, _ := parseDate(r.Str("date"))
if i == 0 {
consecutive = 1
} else {
if math.Round(prev.Sub(cur).Hours()/24) == 1 {
consecutive++
} else {
break
}
}
prev = cur
}
return consecutive
}
// checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra)
// happiness < 0 表示 null(不检查心情成就)
func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int64, rename bool) {
db := c.DB
rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID)
unlocked := map[string]bool{}
for _, r := range rs {
unlocked[r.Str("achievement_key")] = true
}
var toUnlock []string
add := func(cond bool, key string) {
if cond && !unlocked[key] {
toUnlock = append(toUnlock, key)
}
}
add(true, "first_adopt")
add(level >= 5, "level_5")
add(level >= 10, "level_10")
add(level >= 20, "level_20")
add(level >= 30, "level_30")
add(stage >= 2, "stage_2")
add(stage >= 3, "stage_3")
add(stage >= 4, "stage_4")
add(stage >= 5, "stage_5")
if p, _ := db.QueryOne("SELECT adopted_at FROM pets WHERE couple_id = ?", coupleID); p != nil && p.Str("adopted_at") != "" {
if t, err := parseDate(p.Str("adopted_at")); err == nil {
days := int64(math.Floor(time.Since(t).Seconds() / 86400))
add(days >= 7, "adopt_7days")
add(days >= 30, "adopt_30days")
add(days >= 100, "adopt_100days")
}
}
add(happiness >= 0 && happiness >= 100, "happiness_100")
if !unlocked["feed_100"] || !unlocked["walk_50"] || !unlocked["bath_30"] || !unlocked["play_50"] {
if cnt, _ := db.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); cnt != nil {
add(cnt.Int("feed_cnt") >= 100, "feed_100")
add(cnt.Int("walk_cnt") >= 50, "walk_50")
add(cnt.Int("bath_cnt") >= 30, "bath_30")
add(cnt.Int("play_cnt") >= 50, "play_50")
}
}
if !unlocked["all_done_7"] || !unlocked["all_done_30"] {
done, _ := db.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID)
consecutive := consecutiveDoneDays(done)
add(consecutive >= 7, "all_done_7")
add(consecutive >= 30, "all_done_30")
}
add(rename, "rename_first")
if len(toUnlock) == 0 {
return
}
// pet_dynamics.user_id 有外键指向 users,而系统动态写 user_id=0:PHP 版本在此处因外键失败而 500,
// Go 版本在独占连接上临时关闭外键检查写入(语义即"系统"动态)。
db.ExecNoFK(func(q store.Querier) error {
for _, key := range toUnlock {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, ?)", coupleID, key)
a := petAchByKey(key)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:"+a.Name+" "+a.Icon)
}
return nil
})
}
// coupleUserIDsOrdered 对应 SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2 → [user1, user2](缺省 0)
func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID int64) (int64, int64) {
r1, r2 := coupleUserIDsRaw(c, coupleID)
return store.ToInt(r1), store.ToInt(r2)
}
// coupleUserIDsRaw 同上,但保留 PHP 的原始值类型($users[0] ?? 0:存在为行值,缺省为整数 0)
func coupleUserIDsRaw(c *httpapi.Ctx, coupleID int64) (any, any) {
rs, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2", coupleID)
var u1, u2 any = int64(0), int64(0)
if len(rs) > 0 {
u1 = rs[0]["id"]
}
if len(rs) > 1 {
u2 = rs[1]["id"]
}
return u1, u2
}
func petInfo(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID)
if err != nil {
return err
}
if pet == nil {
return c.OK(M{"has_pet": false, "pet": nil})
}
if fa, _ := c.DB.QueryOne("SELECT id FROM pet_achievements WHERE couple_id = ? AND achievement_key = 'first_adopt'", coupleID); fa == nil {
c.DB.ExecNoFK(func(q store.Querier) error {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, 'first_adopt')", coupleID)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣")
return nil
})
}
daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, today())
achs, _ := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ?", coupleID)
achKeys := []any{}
for _, a := range achs {
achKeys = append(achKeys, a["achievement_key"])
}
equipped, _ := c.DB.Query("SELECT item_key, item_type FROM pet_items WHERE couple_id = ? AND equipped = 1", coupleID)
dynamics, _ := c.DB.Query("SELECT pd.*, u.nickname FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT 20", coupleID)
u1raw, u2raw := coupleUserIDsRaw(c, coupleID)
u1, u2 := store.ToInt(u1raw), store.ToInt(u2raw)
expForNext := pet.Int("level") * 100
var expProgress any = 0
if expForNext > 0 {
p := math.Round(float64(pet.Int("exp")) / float64(expForNext) * 100)
if p > 100 {
p = 100
}
expProgress = p
}
counts := M{"feed": 0, "walk": 0, "bath": 0, "play": 0}
if ac, _ := c.DB.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); ac != nil {
counts = M{"feed": ac.Int("feed_cnt"), "walk": ac.Int("walk_cnt"), "bath": ac.Int("bath_cnt"), "play": ac.Int("play_cnt")}
}
done, _ := c.DB.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID)
var dailyOut any
if daily != nil {
dailyOut = daily
}
return c.OK(M{
"has_pet": true, "pet": pet, "daily": dailyOut, "achievements": achKeys, "equipped": rows(equipped), "dynamics": rows(dynamics),
"user1_id": u1raw, "user2_id": u2raw, "is_user1": uid == u1, "is_user2": uid == u2,
"exp_for_next_level": expForNext, "exp_progress": expProgress, "action_counts": counts,
"consecutive_done_days": consecutiveDoneDays(done),
})
}
func petAdopt(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
name := c.InTrim("name")
if !c.InHas("name") {
name = "小狗狗"
}
breed := c.InTrim("breed")
if !c.InHas("breed") {
breed = "golden"
}
gender := int64(1)
if c.InHas("gender") {
gender = c.InInt("gender")
}
switch breed {
case "golden", "husky", "corgi", "shiba":
default:
breed = "golden"
}
if ex, _ := c.DB.QueryOne("SELECT id FROM pets WHERE couple_id = ?", coupleID); ex != nil {
return c.Err(400, "已领养过宠物")
}
petID, err := c.DB.Insert("INSERT INTO pets (couple_id, name, breed, gender, stage, level, exp, happiness, hunger, energy, cleanliness, adopted_at, created_by) VALUES (?, ?, ?, ?, 1, 1, 0, 100, 100, 100, 100, ?, ?)",
coupleID, name, breed, gender, today(), uid)
if err != nil {
return err
}
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'adopt', ?)", coupleID, uid, "领养了"+name+"!🎉")
logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "")
c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID)
checkPetAchievements(c, coupleID, 1, 1, 100, false)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
// PHP 中 $actId 未定义(null),不写 notify_log
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕",
}, "pages/pet/pet", 0, uid)
}
return c.OK(M{"success": true, "pet_id": fmt.Sprint(petID)})
}
var petActionEffects = map[string]map[string]int64{
"feed": {"exp": 10, "happiness": 3, "hunger": 15},
"water": {"exp": 8, "happiness": 2, "energy": 5},
"walk": {"exp": 15, "happiness": 8, "energy": -10, "cleanliness": -5},
"bath": {"exp": 12, "happiness": 5, "cleanliness": 20, "energy": -5},
"sleep": {"exp": 5, "energy": 20, "happiness": 3},
"play": {"exp": 12, "happiness": 10, "energy": -8},
}
var petActionMessages = map[string]string{
"feed": "喂食了宠物 🍖", "water": "给宠物喝了水 💧", "walk": "带宠物去散步 🐾",
"bath": "给宠物洗了澡 🛁", "sleep": "哄宠物睡觉了 😴", "play": "和宠物玩耍了 🎾",
}
var petDailyFields = []string{"feed_u1", "feed_u2", "water_u1", "water_u2", "walk_u1", "walk_u2", "bath_u1", "bath_u2", "sleep_u1", "sleep_u2", "play_u1", "play_u2"}
func clamp100(v int64) int64 {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
func petDailyAction(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
action := c.InTrim("action")
effects, ok := petActionEffects[action]
if !ok {
return c.Err(400, "无效的动作")
}
pet, _ := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID)
if pet == nil {
return c.Err(400, "请先领养宠物")
}
u1, _ := coupleUserIDsOrdered(c, coupleID)
suffix := "u2"
if uid == u1 {
suffix = "u1"
}
td := today()
daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td)
if daily == nil {
if _, err := c.DB.Exec("INSERT INTO pet_dailies (couple_id, date) VALUES (?, ?)", coupleID, td); err != nil {
return err
}
daily, _ = c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td)
}
field := action + "_" + suffix
if daily.Int(field) == 1 {
return c.JSON(400, M{"error": "你今天已完成此任务", "already_done": true})
}
if _, err := c.DB.Exec("UPDATE pet_dailies SET "+field+" = 1 WHERE id = ?", daily.Int("id")); err != nil {
return err
}
allDone := true
for _, f := range petDailyFields {
if daily.Int(f) == 0 && f != field {
row, _ := c.DB.QueryOne("SELECT "+f+" FROM pet_dailies WHERE id = ?", daily.Int("id"))
if row == nil || row.Int(f) == 0 {
allDone = false
break
}
}
}
newHappiness := clamp100(pet.Int("happiness") + effects["happiness"])
newHunger := clamp100(pet.Int("hunger") + effects["hunger"])
newEnergy := clamp100(pet.Int("energy") + effects["energy"])
newClean := clamp100(pet.Int("cleanliness") + effects["cleanliness"])
newExp := pet.Int("exp") + effects["exp"]
newLevel := pet.Int("level")
// PHP: $newLevel = $pet['level'] 为行值(8.0 下是字符串),只有 ++ 后才变为整数;$newStage 同理
var levelOut, stageOut any = pet["level"], pet["stage"]
expNeeded := newLevel * 100
for newExp >= expNeeded {
newExp -= expNeeded
newLevel++
levelOut = newLevel
expNeeded = newLevel * 100
}
newStage := pet.Int("stage")
switch {
case newLevel >= 30:
newStage = 5
case newLevel >= 20:
newStage = 4
case newLevel >= 12:
newStage = 3
case newLevel >= 5:
newStage = 2
}
if newStage != pet.Int("stage") || newLevel >= 5 {
stageOut = newStage
}
if _, err := c.DB.Exec("UPDATE pets SET happiness = ?, hunger = ?, energy = ?, cleanliness = ?, exp = ?, level = ?, stage = ?, updated_at = NOW() WHERE id = ?",
newHappiness, newHunger, newEnergy, newClean, newExp, newLevel, newStage, pet.Int("id")); err != nil {
return err
}
msg := petActionMessages[action]
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg)
if allDone {
c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Int("id"))
c.DB.Exec("UPDATE pets SET exp = exp + 20, happiness = happiness + 10, updated_at = NOW() WHERE id = ?", pet.Int("id"))
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'all_done', '今日所有任务完成!太棒了 🎉')", coupleID, uid)
logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "")
}
checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false)
logActivity(c.DB, coupleID, uid, "pet_daily", msg, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
notifyMsg := msg
if allDone {
notifyMsg = msg + ",今日所有任务完成!🎉"
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物日常 🐾", "thing2": notifyMsg, "date3": td, "thing4": "点击查看宠物详情 💕",
}, "pages/pet/pet", 0, uid)
}
return c.OK(M{
"success": true, "exp_gain": effects["exp"], "happiness": newHappiness, "hunger": newHunger, "energy": newEnergy,
"cleanliness": newClean, "level": levelOut, "stage": stageOut, "all_done": allDone,
})
}
func petDailyStatus(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
date := c.Q("date")
if !c.QHas("date") {
date = today()
}
daily, err := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, date)
if err != nil {
return err
}
u1, _ := coupleUserIDsOrdered(c, coupleID)
suffix, partnerSuffix := "u2", "u1"
if uid == u1 {
suffix, partnerSuffix = "u1", "u2"
}
var myDone, partnerDone any = []any{}, []any{}
var allDone any = false
if daily != nil {
md, pd := M{}, M{}
for _, a := range []string{"feed", "water", "walk", "bath", "sleep", "play"} {
md[a] = daily.Int(a+"_"+suffix) == 1
pd[a] = daily.Int(a+"_"+partnerSuffix) == 1
}
myDone, partnerDone = md, pd
allDone = daily["all_done"]
}
return c.OK(M{"date": date, "has_record": daily != nil, "my_done": myDone, "partner_done": partnerDone, "all_done": allDone})
}
func petAchievements(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
unlocked, err := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ? ORDER BY unlocked_at ASC", coupleID)
if err != nil {
return err
}
at := map[string]any{}
for _, u := range unlocked {
k := u.Str("achievement_key")
if _, seen := at[k]; !seen {
at[k] = u["unlocked_at"]
}
}
result := make([]M, 0, len(allPetAchievements))
for _, a := range allPetAchievements {
ua, ok := at[a.Key]
if !ok {
ua = nil
}
result = append(result, M{"key": a.Key, "name": a.Name, "desc": a.Desc, "icon": a.Icon, "unlocked": ok, "unlocked_at": ua})
}
return c.OK(M{"achievements": result})
}
func petRename(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
name := c.InTrim("name")
if !store.Truthy(name) || len([]rune(name)) > 20 {
return c.Err(400, "名称长度1-20个字符")
}
if _, err := c.DB.Exec("UPDATE pets SET name = ?, updated_at = NOW() WHERE couple_id = ?", name, coupleID); err != nil {
return err
}
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'rename', ?)", coupleID, uid, "给宠物改名为"+name+" ✏️")
logActivity(c.DB, coupleID, uid, "pet_rename", "给宠物改名为"+name, "")
info, _ := c.DB.QueryOne("SELECT level, stage, happiness FROM pets WHERE couple_id = ?", coupleID)
var level, stage, happiness int64
if info != nil {
level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness")
}
checkPetAchievements(c, coupleID, level, stage, happiness, true)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕",
}, "pages/pet/pet", 0, uid)
}
return c.OK(M{"success": true, "name": name})
}
func petEquip(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
itemKey := c.InTrim("item_key")
equip := int64(1)
if c.InHas("equip") {
equip = c.InInt("equip")
}
if !store.Truthy(itemKey) {
return c.Err(400, "缺少物品标识")
}
item, _ := c.DB.QueryOne("SELECT * FROM pet_items WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
if item == nil {
return c.Err(400, "物品不存在")
}
if equip != 0 {
c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_type = ? AND item_key != ?", coupleID, item["item_type"], itemKey)
c.DB.Exec("UPDATE pet_items SET equipped = 1 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
} else {
c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
}
return c.OK(M{"success": true, "equipped": equip == 1})
}
func petDynamics(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
page := int64(1)
if c.QHas("page") {
page = c.QInt("page")
}
limit := int64(20)
offset := (page - 1) * limit
dynamics, err := c.DB.Query("SELECT pd.*, u.nickname, u.avatar FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT ? OFFSET ?", coupleID, limit, offset)
if err != nil {
return err
}
totalRaw := countRaw(c, "total", "SELECT COUNT(*) as total FROM pet_dynamics WHERE couple_id = ?", coupleID)
total := store.ToInt(totalRaw)
return c.OK(M{"dynamics": rows(dynamics), "total": totalRaw, "page": page, "has_more": (offset + limit) < total})
}
+99
View File
@@ -0,0 +1,99 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
)
func registerSongs(r *httpapi.Router) {
r.Handle("songs_list", songsList)
r.Handle("songs_add", songsAdd)
r.Handle("songs_update", songsUpdate)
r.Handle("songs_delete", songsDelete)
}
func songsList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK(demoData("songs"))
}
items, err := c.DB.Query("SELECT s.*, u.nickname, u.avatar FROM songs s LEFT JOIN users u ON s.created_by = u.id WHERE s.created_by IN (?, ?) ORDER BY s.created_at DESC", ids[0], ids[1])
if err != nil {
return err
}
return c.OK(rows(items))
}
func songText(c *httpapi.Ctx) string {
return strings.TrimSpace(c.InStr("song_name") + " " + c.InStr("artist") + " " + c.InStr("note"))
}
func songsAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
if !c.InBool("song_name") || !c.InBool("artist") {
return c.Err(400, "请填写歌曲名和歌手名")
}
uid := c.UID()
if err := checkTextOr400(c, songText(c)); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO songs (couple_id, song_name, artist, note, created_by) VALUES (?, ?, ?, ?, ?)",
cid, c.In("song_name"), c.In("artist"), inOr(c, "note", ""), uid)
if err != nil {
return err
}
name, artist := c.InStr("song_name"), c.InStr("artist")
actID := logActivity(c.DB, cid, uid, "song_add", "收藏了「"+name+" - "+artist+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "收藏了新歌曲 🎵", "thing2": mbTruncate(name, 20) + " - " + artist, "date3": today(), "thing4": "点击听听看 💕",
}, "pages/lovewords/lovewords", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func songsUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if !c.InBool("id") {
return c.Err(400, "参数错误")
}
if !c.InBool("song_name") || !c.InBool("artist") {
return c.Err(400, "请填写歌曲名和歌手名")
}
if err := checkTextOr400(c, songText(c)); err != nil {
return err
}
if _, err := c.DB.Exec("UPDATE songs SET song_name = ?, artist = ?, note = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("song_name"), c.In("artist"), inOr(c, "note", ""), c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func songsDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM songs WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
+145
View File
@@ -0,0 +1,145 @@
package handler
import (
"fmt"
"strings"
"past/internal/httpapi"
)
func registerTimeline(r *httpapi.Router) {
r.Handle("timeline_list", timelineList)
r.Handle("timeline_add", timelineAdd)
r.Handle("timeline_update", timelineUpdate)
r.Handle("timeline_delete", timelineDelete)
r.Handle("timeline_detail", timelineDetail)
}
// requireCoupleUserIds 对应 $userIds = getCoupleUserIds(); if (empty($userIds)) jsonResponse(['error' => '请先绑定情侣'], 400);
func requireCoupleUserIds(c *httpapi.Ctx) ([]int64, error) {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return nil, c.Err(400, "请先绑定情侣")
}
return ids, nil
}
// requireCoupleID 对应 $cid = getCoupleId(); if (!$cid) jsonResponse(['error' => '请先绑定情侣'], 400);
func requireCoupleID(c *httpapi.Ctx) (int64, error) {
cid := c.CoupleID()
if cid == 0 {
return 0, c.Err(400, "请先绑定情侣")
}
return cid, nil
}
// checkTextOr400 文本安全检测;违规时已写出 400
func checkTextOr400(c *httpapi.Ctx, text string) error {
if pass, msg := c.App.Wechat.CheckText(text, userOpenId(c.DB, c.UID())); !pass {
return c.Err(400, msg)
}
return nil
}
func timelineList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0)
if len(ids) == 0 {
return c.OK(demoData("timeline"))
}
items, err := c.DB.Query("SELECT * FROM timeline WHERE created_by IN (?, ?) ORDER BY event_date DESC, sort_order ASC", ids[0], ids[1])
if err != nil {
return err
}
for _, it := range items {
it["images"] = jsonDecodeList(it["images"])
}
return c.OK(rows(items))
}
func timelineAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO timeline (couple_id, title, content, event_date, location, repeat_interval, repeat_unit, bg_image, images, icon, sort_order, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""),
jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), uid)
if err != nil {
return err
}
title := c.InStr("title")
actID := logActivity(c.DB, cid, uid, "timeline_create", "添加了记忆日「"+title+"」", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "记忆日更新",
"thing2": "TA记录了「" + mbTruncate(title, 20) + "」",
"date3": c.InStr("event_date"),
"thing4": "点击查看详情 💕",
}, "pages/timeline/timeline", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func timelineUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
_, err = c.DB.Exec("UPDATE timeline SET title = ?, content = ?, event_date = ?, location = ?, repeat_interval = ?, repeat_unit = ?, bg_image = ?, images = ?, icon = ?, sort_order = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), c.In("event_date"), inOr(c, "location", ""), inOr(c, "repeat_interval", 0), inOr(c, "repeat_unit", ""), inOr(c, "bg_image", ""),
jsonEncode(inOr(c, "images", []any{})), inOr(c, "icon", "❤️"), inOr(c, "sort_order", 0), c.In("id"), ids[0], ids[1])
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func timelineDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM timeline WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func timelineDetail(c *httpapi.Ctx) error {
if err := c.RequireGET(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.Q("id")
if id == "" || id == "0" {
return c.Err(400, "缺少ID")
}
item, err := c.DB.QueryOne("SELECT * FROM timeline WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "记录不存在")
}
item["images"] = jsonDecodeList(item["images"])
return c.OK(item)
}
+639
View File
@@ -0,0 +1,639 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
"past/internal/geo"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
var phoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
// hashPassword 对应 password_hash($pw, PASSWORD_DEFAULT),输出 $2y$ 前缀与 PHP 一致
func hashPassword(pw string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return "$2y$" + strings.TrimPrefix(string(h), "$2a$")
}
// verifyPassword 对应 password_verify()
func verifyPassword(pw, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
// last4 对应 substr($s, -4)
func last4(s string) string {
if len(s) <= 4 {
return s
}
return s[len(s)-4:]
}
// ensureUserDirs 创建用户独立文件夹 users/{uid}/{photos,diaries,covers,timeline}
func ensureUserDirs(uid int64) {
base := filepath.Join(up.Dir, "users", fmt.Sprint(uid))
for _, t := range []string{"photos", "diaries", "covers", "timeline"} {
os.MkdirAll(filepath.Join(base, t), 0755)
}
}
func registerUser(r *httpapi.Router) {
r.Handle("register", register)
r.Handle("register_by_invite", registerByInvite)
r.Handle("login", login)
r.Handle("wechat_login", wechatLogin)
r.Handle("check_auth", checkAuth)
r.Handle("logout", logout)
r.Handle("my_info", myInfo)
r.Handle("update_profile", updateProfile)
r.Handle("update_location", updateLocation)
r.Handle("update_ip_location", updateIPLocation)
r.Handle("update_partner_profile", updatePartnerProfile)
r.Handle("upload_avatar", uploadAvatar)
r.Handle("upload_partner_avatar", uploadPartnerAvatar)
}
func register(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
phone := c.InTrim("phone")
password := c.InStr("password")
nickname := c.InTrim("nickname")
if phone == "" {
return c.Err(400, "请输入手机号")
}
if !phoneRe.MatchString(phone) {
return c.Err(400, "请输入正确的手机号")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil {
return c.Err(400, "该手机号已注册")
}
if len(password) < 6 {
return c.Err(400, "密码至少6位")
}
if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass {
return c.Err(400, msg)
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
displayName := nickname
if displayName == "" {
displayName = "用户" + last4(phone)
}
gender := c.InStr("gender")
if gender != "male" && gender != "female" {
gender = ""
}
region := c.InTrim("region")
userID, err := c.DB.Insert("INSERT INTO users (user_id, phone, username, password, nickname, gender, region) VALUES (?, ?, ?, ?, ?, ?, ?)",
displayID, phone, phone, hashPassword(password), displayName, gender, region)
if err != nil {
return err
}
// PHP 每次请求都会执行 install.php 的回填 UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL,
// 效果等价于注册后下一请求即回填;Go 只在启动时执行迁移,因此在此处直接回填
c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE id = ? AND last_login_at IS NULL", userID)
token := httpapi.NewToken()
c.SaveUserToken(userID, token)
// PHP: user_id 来自 lastInsertId(),为字符串
return c.OK(M{"success": true, "user_id": fmt.Sprint(userID), "display_id": displayID, "username": phone, "nickname": displayName, "couple_id": nil, "region": region, "token": token})
}
func registerByInvite(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
phone := c.InTrim("phone")
password := c.InStr("password")
nickname := c.InTrim("nickname")
inviteCode := c.InTrim("invite_code")
if phone == "" {
return c.Err(400, "请输入手机号")
}
if !phoneRe.MatchString(phone) {
return c.Err(400, "请输入正确的手机号")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil {
return c.Err(400, "该手机号已注册")
}
if len(password) < 6 {
return c.Err(400, "密码至少6位")
}
if inviteCode == "" {
return c.Err(400, "缺少邀请码")
}
if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass {
return c.Err(400, msg)
}
link, err := c.DB.QueryOne("SELECT il.*, u.couple_id as inviter_couple_id FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", inviteCode)
if err != nil {
return err
}
if link == nil {
return c.Err(400, "邀请链接无效或已使用")
}
if link.Int("inviter_couple_id") != 0 {
return c.Err(400, "邀请人已绑定情侣")
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
displayName := nickname
if displayName == "" {
displayName = "用户" + last4(phone)
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
newUserID, err := tx.Insert("INSERT INTO users (user_id, phone, username, password, nickname) VALUES (?, ?, ?, ?, ?)",
displayID, phone, phone, hashPassword(password), displayName)
if err != nil {
return err
}
inviterID := link.Int("user_id")
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", inviterID, newUserID)
if err != nil {
return err
}
spaceID := generateCoupleSpaceId(coupleID)
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", spaceID, coupleID); err != nil {
return err
}
ensureUserDirs(inviterID)
ensureUserDirs(newUserID)
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, inviterID, newUserID); err != nil {
return err
}
if _, err := tx.Exec("UPDATE invite_links SET used = 1 WHERE id = ?", link.Int("id")); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, newUserID)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newUserID)
token := httpapi.NewToken()
c.SaveUserToken(newUserID, token)
return c.OK(M{"success": true, "user_id": fmt.Sprint(newUserID), "display_id": displayID, "username": phone, "couple_id": fmt.Sprint(coupleID), "nickname": displayName, "token": token})
}
func login(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
loginField := c.InTrim("phone")
if loginField == "" {
loginField = c.InTrim("username")
}
password := c.InStr("password")
if loginField == "" {
return c.Err(400, "请输入手机号")
}
user, err := c.DB.QueryOne("SELECT * FROM users WHERE username = ? OR phone = ?", loginField, loginField)
if err != nil {
return err
}
if user == nil {
return c.Err(400, "用户不存在,请先注册")
}
if !verifyPassword(password, user.Str("password")) {
return c.Err(401, "用户名或密码错误")
}
token := httpapi.NewToken()
uid := user.Int("id")
c.SaveUserToken(uid, token)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if loc := resolveLoginLocation(c, ""); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
}
if user.Int("couple_id") != 0 {
logActivity(c.DB, user.Int("couple_id"), uid, "login", "登录了小程序", "")
}
return c.OK(M{"success": true, "user_id": user["id"], "display_id": user["user_id"], "username": user["username"], "nickname": user["nickname"],
"couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token})
}
func wechatLogin(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
code := c.InTrim("code")
if code == "" {
return c.Err(400, "缺少登录凭证")
}
openid, wxErr, available := c.App.Wechat.Jscode2Session(code)
if !available {
return c.Err(500, "微信登录服务暂不可用")
}
if wxErr != "" {
return c.Err(400, "微信登录失败: "+wxErr)
}
if openid == "" {
return c.Err(400, "获取用户标识失败")
}
user, err := c.DB.QueryOne("SELECT id, user_id, nickname, avatar, gender, birthday, couple_id, region FROM users WHERE openid = ?", openid)
if err != nil {
return err
}
if user != nil {
uid := user.Int("id")
token := httpapi.NewToken()
c.SaveUserToken(uid, token)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
region := c.InTrim("region")
if region != "" && user.Str("region") == "" {
c.DB.Exec("UPDATE users SET region = ? WHERE id = ?", region, uid)
user["region"] = region
}
if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
}
if cid := user.Int("couple_id"); cid != 0 {
actID := logActivity(c.DB, cid, uid, "login", "登录了小程序", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
nick := user.Str("nickname")
if nick == "" {
nick = "你的TA"
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": nick + " 登录了小程序",
"thing2": "TA已上线,快来互动吧 💕",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/index/index", actID, uid)
}
}
return c.OK(M{
"success": true, "is_new": false,
"user_id": user["id"], "display_id": user["user_id"],
"couple_id": user["couple_id"], "nickname": user["nickname"],
"avatar": user.Str("avatar"), "gender": user.Str("gender"),
"birthday": user.Str("birthday"), "region": user.Str("region"),
"token": token,
})
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
region := c.InTrim("region")
defaultNick := "用户" + last4(displayID)
newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region) VALUES (?, ?, ?, ?, ?, ?)",
displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region)
if err != nil {
return err
}
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newID)
if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, newID)
}
token := httpapi.NewToken()
c.SaveUserToken(newID, token)
return c.OK(M{
"success": true, "is_new": true,
"user_id": fmt.Sprint(newID), "display_id": displayID,
"couple_id": nil, "nickname": defaultNick,
"avatar": "", "gender": "", "birthday": "", "region": region,
"token": token,
})
}
func checkAuth(c *httpapi.Ctx) error {
uid := c.UID()
if uid == 0 {
return c.OK(M{"logged_in": false})
}
// 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同
if user, _ := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid); user != nil {
if needLoginRecord(user.Str("last_login_at")) {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid)
}
}
}
user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id FROM users WHERE id = ?", uid)
if err != nil {
return err
}
return c.OK(M{"logged_in": true, "user": user})
}
func logout(c *httpapi.Ctx) error {
if uid := c.UID(); uid != 0 {
// PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理
c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token())
}
return c.OK(M{"success": true})
}
func myInfo(c *httpapi.Ctx) error {
uid := c.UID()
user, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.couple_id, u.created_at FROM users u WHERE u.id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.OK(false)
}
if user.Int("couple_id") != 0 {
partner, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.lat, u.lng FROM users u INNER JOIN couples c ON (u.id = c.user1_id OR u.id = c.user2_id) WHERE c.id = ? AND u.id != ?", user.Int("couple_id"), uid)
if err != nil {
return err
}
if partner == nil {
user["partner"] = false
} else {
user["partner"] = partner
}
}
return c.OK(user)
}
func updateProfile(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
nickname := c.InTrim("nickname")
avatar := c.InStr("avatar")
gender := c.InStr("gender")
if nickname != "" {
if pass, msg := c.App.Wechat.CheckText(nickname, userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
}
hasAge := c.InHas("age")
age := c.InInt("age")
birthday := c.InTrim("birthday")
region := c.InTrim("region")
var fields []string
var params []any
var changed []string
if store.Truthy(nickname) {
fields = append(fields, "nickname = ?")
params = append(params, nickname)
changed = append(changed, "昵称")
}
if store.Truthy(avatar) {
fields = append(fields, "avatar = ?")
params = append(params, avatar)
changed = append(changed, "头像")
}
if store.Truthy(gender) {
fields = append(fields, "gender = ?")
params = append(params, gender)
changed = append(changed, "性别")
}
if hasAge {
fields = append(fields, "age = ?")
params = append(params, age)
changed = append(changed, "年龄")
}
if birthday != "" {
fields = append(fields, "birthday = ?")
params = append(params, birthday)
changed = append(changed, "生日")
}
if region != "" {
fields = append(fields, "region = ?")
params = append(params, region)
changed = append(changed, "地区")
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, uid)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 {
logActivity(c.DB, cid, uid, "profile_edit", "修改了个人资料:"+strings.Join(changed, "、"), "")
}
}
return c.OK(M{"success": true})
}
func updateLocation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
location := c.InTrim("location")
if location == "" {
return c.Err(400, "位置不能为空")
}
saved := location
var lat, lng float64
if strings.Contains(location, ",") {
parts := strings.Split(location, ",")
if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) {
lat = store.ToFloat(strings.TrimSpace(parts[0]))
lng = store.ToFloat(strings.TrimSpace(parts[1]))
if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" {
saved = addr
} else {
old, _ := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", uid)
if old != nil && old.Str("location") != "" && !strings.Contains(old.Str("location"), ",") {
saved = old.Str("location")
} else {
saved = ""
}
}
}
}
if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", saved, lat, lng, uid); err != nil {
return err
}
var distance any
if cid := c.CoupleID(); cid != 0 {
partner, _ := c.DB.QueryOne("SELECT lat, lng FROM users WHERE couple_id = ? AND id != ? AND lat > 0 AND lng > 0", cid, uid)
if partner != nil && lat != 0 && lng != 0 {
distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"]))
}
}
return c.OK(M{"success": true, "location": saved, "lat": lat, "lng": lng, "distance": distance})
}
func updateIPLocation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
var newLat, newLng float64
if c.InHas("lat") {
newLat = c.InFloat("lat")
}
if c.InHas("lng") {
newLng = c.InFloat("lng")
}
u, _ := c.DB.QueryOne("SELECT location, lat, lng, last_ip FROM users WHERE id = ?", uid)
current := ""
var oldLat, oldLng float64
if u != nil {
current = u.Str("location")
oldLat = store.ToFloat(u["lat"])
oldLng = store.ToFloat(u["lng"])
}
if newLat != 0 && newLng != 0 && oldLat != 0 && oldLng != 0 {
dist := geo.Haversine(oldLat, oldLng, newLat, newLng)
if dist <= 5.0 {
return c.OK(M{"success": true, "location": current, "cached": true, "distance": math.Round(dist*100) / 100})
}
}
clientIP := c.ClientIP()
address := ""
if newLat != 0 && newLng != 0 {
address = c.App.Geo.ReverseGeocode(newLat, newLng)
}
if address == "" {
address = c.App.Geo.IPGeolocate(clientIP)
}
if address != "" {
if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, last_ip = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", address, newLat, newLng, clientIP, uid); err != nil {
return err
}
return c.OK(M{"success": true, "location": address, "cached": false})
}
return c.OK(M{"success": true, "location": current, "cached": true})
}
// verifyPartner 校验 partner_id 是当前用户的伴侣;失败时已写出响应
func verifyPartner(c *httpapi.Ctx, uid, partnerID int64) error {
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user == nil || user.Int("couple_id") == 0 {
return c.Err(400, "你还没有绑定情侣")
}
partner, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ? AND couple_id = ?", partnerID, user.Int("couple_id"))
if partner == nil {
return c.Err(400, "无法修改此用户信息")
}
return nil
}
func updatePartnerProfile(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
partnerID := c.InInt("partner_id")
nickname := c.InTrim("nickname")
avatar := c.InStr("avatar")
gender := c.InTrim("gender")
hasAge := c.InHas("age")
age := c.InInt("age")
birthday := c.InTrim("birthday")
if err := verifyPartner(c, uid, partnerID); err != nil {
return err
}
var fields []string
var params []any
var changed []string
if store.Truthy(nickname) {
fields = append(fields, "nickname = ?")
params = append(params, nickname)
changed = append(changed, "昵称")
}
if store.Truthy(avatar) {
fields = append(fields, "avatar = ?")
params = append(params, avatar)
changed = append(changed, "头像")
}
if store.Truthy(gender) {
fields = append(fields, "gender = ?")
params = append(params, gender)
changed = append(changed, "性别")
}
if hasAge {
fields = append(fields, "age = ?")
params = append(params, age)
changed = append(changed, "年龄")
}
if birthday != "" {
fields = append(fields, "birthday = ?")
params = append(params, birthday)
changed = append(changed, "生日")
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, partnerID)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 {
logActivity(c.DB, cid, uid, "partner_edit", "修改了TA的资料:"+strings.Join(changed, "、"), "")
}
}
return c.OK(M{"success": true})
}
// doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file
func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error {
u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", targetUID)
uidPath := fmt.Sprintf("uid%d", targetUID)
if u != nil {
uidPath = u.Str("user_id")
}
fh := c.File("avatar")
if fh == nil {
fh = c.File("file")
}
if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
res := up.Handle(fh, "avatars", upload.AvatarPath(uidPath))
if res.Path != "" {
if _, err := c.DB.Exec("UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", res.Path, targetUID); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
msg := res.Error
if msg == "" {
msg = "未知错误"
}
return c.ErrCode(400, "上传失败: "+msg, upload.ErrHandleFailed)
}
func uploadAvatar(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
return doAvatarUpload(c, c.UID())
}
func uploadPartnerAvatar(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
uid := c.UID()
partnerID := store.ToInt(c.Form("partner_id"))
if err := verifyPartner(c, uid, partnerID); err != nil {
return err
}
return doAvatarUpload(c, partnerID)
}
+182
View File
@@ -0,0 +1,182 @@
package httpapi
import (
"crypto/rand"
"encoding/hex"
"net/http"
"regexp"
"sync"
"time"
)
var bearerRe = regexp.MustCompile(`Bearer\s(\S+)`)
// Token 对应 getBearerToken() + $_POST['token'] + $_GET['token'] 三通道
func (c *Ctx) Token() string {
if m := bearerRe.FindStringSubmatch(c.G.GetHeader("Authorization")); m != nil {
return m[1]
}
if t := c.Form("token"); t != "" {
return t
}
if t := c.Q("token"); t != "" {
return t
}
return ""
}
// UID 对应 getUserId():通过 token 查 user_tokens;未登录返回 0。
func (c *Ctx) UID() int64 {
if c.uidResolved {
return c.uid
}
c.uidResolved = true
tok := c.Token()
if tok == "" {
return 0
}
row, err := c.DB.QueryOne("SELECT user_id FROM user_tokens WHERE token = ? AND expires_at > NOW()", tok)
if err != nil || row == nil {
return 0
}
c.uid = row.Int("user_id")
return c.uid
}
// CoupleID 对应 getCoupleId();无则返回 0
func (c *Ctx) CoupleID() int64 {
uid := c.UID()
if uid == 0 {
return 0
}
row, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if row == nil {
return 0
}
return row.Int("couple_id")
}
// NewToken 生成 32 位 hex token(与 PHP session_id 格式一致)
func NewToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// SaveUserToken 对应 saveUserToken(),写入新 token(7 天)并清理过期 token。
// PHP 会删除该用户旧 token,但旧 session 文件仍然有效(实际上允许多端同时登录);
// Go 没有 session 文件,因此保留旧 token 以维持同样的多端登录效果。
func (c *Ctx) SaveUserToken(userID int64, token string) {
c.DB.Exec("DELETE FROM user_tokens WHERE expires_at < NOW()")
c.DB.Exec("INSERT INTO user_tokens (token, user_id, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))", token, userID)
}
// requireUserLogin 对应 requireUserLogin():401/404,并做 5 分钟去重的登录记录
func (c *Ctx) requireUserLogin() error {
uid := c.UID()
if uid == 0 {
return c.Err(401, "请先登录")
}
user, err := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.Err(404, "用户不存在")
}
needRecord := false
if user.IsNull("last_login_at") || user.Str("last_login_at") == "" {
needRecord = true
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", user.Str("last_login_at"), time.Local); err != nil || time.Since(t) > 300*time.Second {
needRecord = true
}
if needRecord {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid)
}
}
return nil
}
// ---------- 后台管理员会话(对应 PHP Cookie Session)----------
const adminCookieName = "PHPSESSID"
const adminSessionTTL = 12 * time.Hour
type adminSession struct {
AdminID int64
Username string
Expires time.Time
}
type AdminSessions struct {
mu sync.Mutex
m map[string]*adminSession
}
func NewAdminSessions() *AdminSessions {
s := &AdminSessions{m: map[string]*adminSession{}}
go func() {
for range time.Tick(10 * time.Minute) {
s.mu.Lock()
now := time.Now()
for k, v := range s.m {
if v.Expires.Before(now) {
delete(s.m, k)
}
}
s.mu.Unlock()
}
}()
return s
}
func (s *AdminSessions) get(id string) *adminSession {
s.mu.Lock()
defer s.mu.Unlock()
v := s.m[id]
if v == nil || v.Expires.Before(time.Now()) {
delete(s.m, id)
return nil
}
v.Expires = time.Now().Add(adminSessionTTL)
return v
}
// AdminSession 当前请求的管理员会话;未登录返回 nil
func (c *Ctx) AdminSession() *adminSession {
id, err := c.G.Cookie(adminCookieName)
if err != nil || id == "" {
return nil
}
return c.App.Admin.get(id)
}
// AdminLogin 建立管理员会话并写 Cookie(对应 session_start + $_SESSION['admin_logged_in']=true)
func (c *Ctx) AdminLogin(adminID int64, username string) {
id := NewToken()
c.App.Admin.mu.Lock()
c.App.Admin.m[id] = &adminSession{AdminID: adminID, Username: username, Expires: time.Now().Add(adminSessionTTL)}
c.App.Admin.mu.Unlock()
c.G.SetSameSite(http.SameSiteLaxMode)
c.G.SetCookie(adminCookieName, id, 0, "/", "", false, true)
}
// AdminLogout 对应 session_destroy()
func (c *Ctx) AdminLogout() {
if id, err := c.G.Cookie(adminCookieName); err == nil {
c.App.Admin.mu.Lock()
delete(c.App.Admin.m, id)
c.App.Admin.mu.Unlock()
}
c.G.SetCookie(adminCookieName, "", -1, "/", "", false, true)
}
// requireAdminLogin 对应 requireAdminLogin()
func (c *Ctx) requireAdminLogin() error {
if c.AdminSession() == nil {
return c.Err(401, "未登录或登录已过期")
}
return nil
}
+316
View File
@@ -0,0 +1,316 @@
// Package httpapi 提供与 PHP 版 index.php 等价的请求上下文、路由分发、鉴权和响应封装。
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"past/internal/config"
"past/internal/geo"
"past/internal/store"
"past/internal/wechat"
)
// App 全局依赖
type App struct {
Cfg *config.Config
DB *store.DB
Wechat *wechat.Client
Geo *geo.Client
Admin *AdminSessions
}
// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。
type Ctx struct {
G *gin.Context
W http.ResponseWriter
R *http.Request
App *App
DB *store.DB
Action string
CamelCase bool
Method string
input map[string]any
inputParsed bool
body []byte
bodyRead bool
uid int64
uidResolved bool
responded bool
}
// errResponded 表示响应已写出(对应 PHP 的 exit)。
var errResponded = errors.New("responded")
// IsResponded 判断错误是否为“已响应”哨兵
func IsResponded(err error) bool { return errors.Is(err, errResponded) }
// ---------- 响应 ----------
// JSON 对应 jsonResponse($data, $code)。不用 gin 自带的 c.JSON(它会把 <>& 转义成 \u003c),
// 而是自行编码后通过 c.Data 输出,保持与 PHP json_encode(JSON_UNESCAPED_UNICODE) 一致。
func (c *Ctx) JSON(code int, v any) error {
if c.responded {
return errResponded
}
c.responded = true
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
c.G.Data(500, "application/json; charset=utf-8", []byte(`{"error":"json encode failed"}`))
return errResponded
}
if c.CamelCase {
var payload any
dec := json.NewDecoder(bytes.NewReader(buf.Bytes()))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return err
}
buf.Reset()
if err := enc.Encode(renameKeys(payload, camelName)); err != nil {
return err
}
}
c.G.Data(code, "application/json; charset=utf-8", bytes.TrimRight(buf.Bytes(), "\n"))
return errResponded
}
// OK 返回 200
func (c *Ctx) OK(v any) error { return c.JSON(200, v) }
// Err 对应 jsonResponse(['error' => $msg], $code)
func (c *Ctx) Err(code int, msg string) error {
return c.JSON(code, M{"error": msg})
}
// ErrCode 带错误码的上传错误:['error' => msg, 'code' => n]
func (c *Ctx) ErrCode(code int, msg string, errCode int) error {
return c.JSON(code, M{"error": msg, "code": errCode})
}
// M 便捷的 JSON 对象类型
type M = map[string]any
// RequirePOST 对应 if ($method !== 'POST') jsonResponse(['error' => '方法不允许'], 405);
func (c *Ctx) RequirePOST() error {
if c.Method != "POST" {
return c.Err(405, "方法不允许")
}
return nil
}
func (c *Ctx) RequireGET() error {
if c.Method != "GET" {
return c.Err(405, "方法不允许")
}
return nil
}
// ---------- 请求体 / 参数 ----------
// Body 原始请求体(php://input)
func (c *Ctx) Body() []byte {
if !c.bodyRead {
c.bodyRead = true
if c.R.Body != nil {
c.body, _ = io.ReadAll(c.R.Body)
}
}
return c.body
}
// Input 对应 $input = json_decode(file_get_contents('php://input'), true);解析失败返回空 map。
func (c *Ctx) Input() map[string]any {
if !c.inputParsed {
c.inputParsed = true
c.input = map[string]any{}
// Multipart fields belong to Form; reading them as JSON would consume the upload stream.
if c.isForm() {
return c.input
}
b := c.Body()
if len(bytes.TrimSpace(b)) > 0 {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var v any
if dec.Decode(&v) == nil {
if m, ok := v.(map[string]any); ok {
if c.CamelCase {
m = renameKeys(m, snakeName).(map[string]any)
}
c.input = normalizeNumbers(m).(map[string]any)
}
}
}
}
return c.input
}
// normalizeNumbers 把 json.Number 转成 int64 或 float64(整数保持整数,对齐 PHP json_decode)
func normalizeNumbers(v any) any {
switch x := v.(type) {
case json.Number:
if i, err := x.Int64(); err == nil {
return i
}
f, _ := x.Float64()
return f
case map[string]any:
for k, val := range x {
x[k] = normalizeNumbers(val)
}
return x
case []any:
for i, val := range x {
x[i] = normalizeNumbers(val)
}
return x
}
return v
}
// In 对应 $input['key'] ?? nil
func (c *Ctx) In(key string) any { return c.Input()[key] }
// InHas 对应 isset($input['key'])
func (c *Ctx) InHas(key string) bool {
v, ok := c.Input()[key]
return ok && v != nil
}
// InStr 对应 (string)($input['key'] ?? ”)
func (c *Ctx) InStr(key string) string { return store.ToStr(c.In(key)) }
// InTrim 对应 trim($input['key'] ?? ”)
func (c *Ctx) InTrim(key string) string { return strings.TrimSpace(c.InStr(key)) }
// InInt 对应 intval($input['key'] ?? 0)
func (c *Ctx) InInt(key string) int64 { return store.ToInt(c.In(key)) }
// InFloat 对应 floatval($input['key'] ?? 0)
func (c *Ctx) InFloat(key string) float64 { return store.ToFloat(c.In(key)) }
// InBool 对应 PHP 真值判断
func (c *Ctx) InBool(key string) bool { return store.Truthy(c.In(key)) }
// InList 对应 is_array($input['key']) ? $input['key'] : []
func (c *Ctx) InList(key string) []any {
if l, ok := c.In(key).([]any); ok {
return l
}
return []any{}
}
// InMap 对应关联数组
func (c *Ctx) InMap(key string) map[string]any {
if m, ok := c.In(key).(map[string]any); ok {
return m
}
return nil
}
// Q 对应 $_GET['key'] ?? ”
func (c *Ctx) Q(key string) string {
if c.CamelCase {
if value, ok := c.G.GetQuery(camelName(key)); ok {
return value
}
}
return c.G.Query(key)
}
// QHas 对应 isset($_GET['key'])
func (c *Ctx) QHas(key string) bool {
if c.CamelCase {
if _, ok := c.G.GetQuery(camelName(key)); ok {
return true
}
}
_, ok := c.G.GetQuery(key)
return ok
}
// QInt 对应 intval($_GET['key'] ?? 0)
func (c *Ctx) QInt(key string) int64 { return store.ToInt(c.Q(key)) }
// Form 对应 $_POST['key'] ?? ”(表单/multipart 字段)
func (c *Ctx) Form(key string) string {
if !c.isForm() {
return ""
}
if c.CamelCase {
if value, ok := c.G.GetPostForm(camelName(key)); ok {
return value
}
}
return c.G.PostForm(key)
}
// FormHas 对应 isset($_POST['key'])
func (c *Ctx) FormHas(key string) bool {
if !c.isForm() {
return false
}
if c.CamelCase {
if _, ok := c.G.GetPostForm(camelName(key)); ok {
return true
}
}
_, ok := c.G.GetPostForm(key)
return ok
}
// isForm 只有表单/multipart 请求才解析 $_POST(其它类型的 body 留给 Input() 读取 JSON)
func (c *Ctx) isForm() bool {
ct := c.R.Header.Get("Content-Type")
return strings.HasPrefix(ct, "multipart/form-data") || strings.HasPrefix(ct, "application/x-www-form-urlencoded")
}
// File 对应 $_FILES['name'](单文件);不存在返回 nil。
func (c *Ctx) File(name string) *multipart.FileHeader {
fs := c.Files(name)
if len(fs) == 0 {
return nil
}
return fs[0]
}
// Files 对应 $_FILES['name']:与 PHP 一致,同名字段 name 重复时只保留最后一个;name[] 才是多文件数组
func (c *Ctx) Files(name string) []*multipart.FileHeader {
if !strings.HasPrefix(c.R.Header.Get("Content-Type"), "multipart/form-data") {
return nil
}
form, err := c.G.MultipartForm()
if err != nil || form == nil {
return nil
}
if fs := form.File[name]; len(fs) > 0 {
return fs[len(fs)-1:]
}
return form.File[name+"[]"]
}
// ClientIP 对应 getClientIp():X-Forwarded-For → X-Real-IP → RemoteAddr
// (gin 自带的 ClientIP 只信任 TrustedProxies,这里保持 PHP 的宽松取法,Nginx/frp 链路上由 Nginx 设置头)
func (c *Ctx) ClientIP() string {
if xff := c.G.GetHeader("X-Forwarded-For"); xff != "" {
ip := strings.TrimSpace(strings.Split(xff, ",")[0])
if ip != "" && ip != "unknown" {
return ip
}
}
if xr := strings.TrimSpace(c.G.GetHeader("X-Real-Ip")); xr != "" && xr != "unknown" {
return xr
}
return c.G.RemoteIP()
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import "strings"
// camelName is the public API naming convention; DB and handlers keep their established names.
func camelName(s string) string {
parts := strings.Split(s, "_")
for i := 1; i < len(parts); i++ {
if len(parts[i]) > 0 {
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
}
return strings.Join(parts, "")
}
func snakeName(s string) string {
var b strings.Builder
for i, c := range s {
if c >= 'A' && c <= 'Z' {
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(c + ('a' - 'A'))
} else {
b.WriteRune(c)
}
}
return b.String()
}
func renameKeys(v any, name func(string) string) any {
switch x := v.(type) {
case map[string]any:
result := make(map[string]any, len(x))
for k, val := range x {
result[name(k)] = renameKeys(val, name)
}
return result
case []any:
result := make([]any, len(x))
for i, val := range x {
result[i] = renameKeys(val, name)
}
return result
default:
return v
}
}
+243
View File
@@ -0,0 +1,243 @@
package httpapi
import (
"bytes"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束;
// 返回其它 error 对应 PHP 顶层 catch → 500 {"error": message}。
type HandlerFunc func(c *Ctx) error
type Router struct {
app *App
handlers map[string]HandlerFunc
adminFS fs.FS // React 后台构建产物(embed)
}
func NewRouter(app *App, adminFS fs.FS) *Router {
return &Router{app: app, handlers: map[string]HandlerFunc{}, adminFS: adminFS}
}
// Handle 注册 action
func (r *Router) Handle(action string, h HandlerFunc) {
if _, dup := r.handlers[action]; dup {
panic("duplicate action: " + action)
}
r.handlers[action] = h
}
// Actions 已注册的 action 列表(用于自检)
func (r *Router) Actions() []string {
out := make([]string, 0, len(r.handlers))
for k := range r.handlers {
out = append(out, k)
}
return out
}
// 与 index.php 中 $publicActions / $adminActions 完全一致
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
"check_invite_code", "register_by_invite", "check_auth")
var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations")
func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items))
for _, s := range items {
m[s] = true
}
return m
}
var actionInURIRe = regexp.MustCompile(`[?&]action=([^&]+)`)
// Engine 构建 gin 引擎:API 入口、上传文件静态服务、后台 SPA。
func (r *Router) Engine(debugMode bool) *gin.Engine {
if debugMode {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
e := gin.New()
e.Use(gin.Recovery(), accessLog())
e.RedirectTrailingSlash = false
e.MaxMultipartMemory = 32 << 20
// 简洁 API 路径;旧入口保留给已发布客户端和管理后台。
e.Any("/api", r.serveAPI)
e.Any("/api/", r.serveAPI)
e.Any("/api/:action", r.serveAPI)
e.Any("/backend/api/index.php", r.serveAPI)
e.Any("/backend/api/", r.serveAPI)
e.Any("/backend/api", r.serveAPI)
// 上传文件:DB 中路径 uploads/... → URL /backend/uploads/...(gin.Dir 关闭目录列表;缓存 1 个月,对应 .htaccess)
uploadDir := r.app.Cfg.UploadDir()
uploads := e.Group("/backend/uploads", func(c *gin.Context) {
// 只对真实存在的文件加长缓存;404 不能被浏览器缓存(否则文件补上后仍显示不出来)
p := filepath.Join(uploadDir, filepath.FromSlash(strings.TrimPrefix(c.Param("filepath"), "/")))
if st, err := os.Stat(p); err == nil && !st.IsDir() && !strings.Contains(c.Param("filepath"), "..") {
c.Header("Cache-Control", "public, max-age=2592000")
} else {
c.Header("Cache-Control", "no-store")
}
})
uploads.StaticFS("/", gin.Dir(uploadDir, false))
// 管理后台 SPA
e.GET("/backend/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/backend/admin/") })
e.GET("/backend/admin/*filepath", r.serveAdmin)
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
return e
}
func (r *Router) serveAPI(g *gin.Context) {
w, req := g.Writer, g.Request
// CORS(与 PHP 一致)
h := w.Header()
// API 响应按用户身份变化,且权限可能随时修改,不能复用旧的 GET 结果。
h.Set("Cache-Control", "no-store")
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if req.Method == http.MethodOptions {
w.WriteHeader(200)
return
}
c := &Ctx{G: g, W: w, R: req, App: r.app, DB: r.app.DB, Method: req.Method}
g.Set("action", "")
// 路径中的 action 优先,兼容旧客户端的查询和表单参数。
action := g.Param("action")
if action == "" {
action = req.URL.Query().Get("action")
}
if action == "" {
action = c.Form("action")
}
if action == "" {
if m := actionInURIRe.FindStringSubmatch(req.RequestURI); m != nil {
if v, err := url.QueryUnescape(m[1]); err == nil {
action = v
}
}
}
// A camelCase path opts into camelCase request/response fields. Snake paths retain legacy payloads.
publicAction := action
c.CamelCase = g.Param("action") != "" && !strings.Contains(action, "_")
action = snakeName(action)
c.Action = action
g.Set("action", publicAction)
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic", "action", action, "err", rec, "stack", string(debug.Stack()))
if !c.responded {
c.Err(500, fmt.Sprint(rec))
}
}
g.Set("uid", c.uid)
}()
if action == "" {
c.JSON(200, M{
"name": "我们past API",
"version": "2.0.0",
"publicEndpoints": []string{"register", "login", "wechatLogin", "siteInfo", "siteStats", "adminLogin", "checkInviteCode", "registerByInvite"},
})
return
}
// 权限检查(与 PHP 的 if/elseif 顺序一致)
var err error
if adminActions[action] && action != "admin_login" {
err = c.requireAdminLogin()
} else if !publicActions[action] && !adminActions[action] {
err = c.requireUserLogin()
}
if err != nil {
if !IsResponded(err) {
c.Err(500, err.Error())
}
return
}
hf, ok := r.handlers[action]
if !ok {
c.Err(400, "未知操作")
return
}
if err := hf(c); err != nil && !IsResponded(err) {
slog.Error("handler error", "action", action, "err", err)
c.Err(500, err.Error())
}
if !c.responded {
// PHP 中每个分支都以 jsonResponse 结束;兜底返回空对象,避免挂起
c.JSON(200, M{})
}
}
// accessLog gin 中间件:结构化访问日志(slog)
func accessLog() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
attrs := []any{"method", c.Request.Method, "path", c.Request.URL.Path, "status", c.Writer.Status(), "ms", time.Since(start).Milliseconds()}
if a, ok := c.Get("action"); ok {
attrs = append(attrs, "action", a)
}
if uid, ok := c.Get("uid"); ok {
attrs = append(attrs, "uid", uid)
}
slog.Info("http", attrs...)
}
}
// serveAdmin 提供 embed 的后台 SPA;未命中的路径回退到 index.html
func (r *Router) serveAdmin(g *gin.Context) {
p := strings.TrimPrefix(path.Clean("/"+g.Param("filepath")), "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(r.adminFS, p); err != nil {
p = "index.html"
}
if strings.HasSuffix(p, ".html") {
g.Header("Cache-Control", "no-cache")
}
// 不用 http.FileServer:它会把 /index.html 301 到 ./
f, err := r.adminFS.Open(p)
if err != nil {
g.Status(404)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
g.Status(404)
return
}
rs, ok := f.(io.ReadSeeker)
if !ok {
b, _ := io.ReadAll(f)
rs = bytes.NewReader(b)
}
http.ServeContent(g.Writer, g.Request, p, st.ModTime(), rs)
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"past/internal/config"
)
func TestCleanAPIRoutes(t *testing.T) {
app := &App{Cfg: &config.Config{DataDir: t.TempDir()}, Admin: NewAdminSessions()}
router := NewRouter(app, fstest.MapFS{"index.html": {Data: []byte("admin")}})
router.Handle("site_info", func(c *Ctx) error { return c.JSON(200, M{"action": c.Action, "value": c.Q("value")}) })
router.Handle("admin_login", func(c *Ctx) error {
c.AdminLogin(1, "test")
return c.JSON(200, M{"success": true})
})
router.Handle("admin_stats", func(c *Ctx) error { return c.JSON(200, M{"success": true}) })
router.Handle("site_stats", func(c *Ctx) error {
return c.JSON(200, M{"value": c.Form("value"), "token": c.Token()})
})
engine := router.Engine(false)
for _, uri := range []string{"/api/site_info?value=hello", "/api/site_info?action=wrong&value=hello", "/backend/api/index.php?action=site_info&value=hello"} {
t.Run(uri, func(t *testing.T) {
out := httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, uri, nil))
var result map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if out.Code != 200 || result["action"] != "site_info" || result["value"] != "hello" {
t.Fatalf("%d %s", out.Code, out.Body.String())
}
if out.Header().Get("Cache-Control") != "no-store" {
t.Fatal("API responses must not be cached")
}
})
}
for _, uri := range []string{"/api/couple_info", "/api/period_visible_set", "/api/upload", "/api/admin_stats"} {
out := httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, uri, strings.NewReader("{}")))
if out.Code != 401 {
t.Fatalf("unauthenticated %s: %d %s", uri, out.Code, out.Body.String())
}
}
out := httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodOptions, "/api/upload", nil))
if out.Code != 200 || out.Header().Get("Access-Control-Allow-Headers") == "" {
t.Fatal("missing preflight support")
}
// Multipart parameters remain available after routing by URL path.
var body bytes.Buffer
form := multipart.NewWriter(&body)
form.WriteField("value", "upload-value")
form.WriteField("token", "android-form-token")
form.Close()
req := httptest.NewRequest(http.MethodPost, "/api/site_stats", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
out = httptest.NewRecorder()
engine.ServeHTTP(out, req)
if out.Code != 200 || !strings.Contains(out.Body.String(), "android-form-token") || !strings.Contains(out.Body.String(), "upload-value") {
t.Fatalf("multipart response: %d %s", out.Code, out.Body.String())
}
// Admin login and authenticated requests share the existing Cookie session.
out = httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodPost, "/api/admin_login", nil))
cookies := out.Result().Cookies()
if len(cookies) == 0 || cookies[0].Path != "/" {
t.Fatal("admin Cookie must cover the new API paths")
}
req = httptest.NewRequest(http.MethodGet, "/api/admin_stats", nil)
req.AddCookie(cookies[0])
out = httptest.NewRecorder()
engine.ServeHTTP(out, req)
if out.Code != 200 {
t.Fatalf("authenticated admin: %d %s", out.Code, out.Body.String())
}
}
func TestCamelCaseContract(t *testing.T) {
router := NewRouter(&App{Cfg: &config.Config{DataDir: t.TempDir()}, Admin: NewAdminSessions()}, fstest.MapFS{})
router.Handle("site_info", func(c *Ctx) error {
return c.OK(M{"user_id": int64(9007199254740993), "cycle_length": c.InInt("cycle_length"), "profile_data": c.InMap("profile_data"), "page_size": c.Q("page_size"), "period_visible": c.Form("period_visible"), "avatar_path": "uploads/some_file.jpg"})
})
engine := router.Engine(false)
out := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/siteInfo?pageSize=20", strings.NewReader(`{"cycleLength":25,"profileData":{"startDate":"2026-10-05"}}`))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(out, req)
body := out.Body.String()
for _, expected := range []string{`"userId":9007199254740993`, `"cycleLength":25`, `"profileData":{"startDate":"2026-10-05"}`, `"pageSize":"20"`, `uploads/some_file.jpg`} {
if !strings.Contains(body, expected) {
t.Fatalf("missing %s: %d %s", expected, out.Code, body)
}
}
if strings.Contains(body, `"cycle_length"`) {
t.Fatal("snake field leaked onto camel API")
}
// Legacy routes retain their existing JSON fields.
out = httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/backend/api/index.php?action=site_info", nil))
if !strings.Contains(out.Body.String(), `"user_id"`) {
t.Fatal("legacy contract changed")
}
var upload bytes.Buffer
form := multipart.NewWriter(&upload)
form.WriteField("periodVisible", "0")
form.Close()
req = httptest.NewRequest(http.MethodPost, "/api/siteInfo", &upload)
req.Header.Set("Content-Type", form.FormDataContentType())
out = httptest.NewRecorder()
engine.ServeHTTP(out, req)
if !strings.Contains(out.Body.String(), `"periodVisible":"0"`) {
t.Fatalf("camel multipart: %s", out.Body.String())
}
out = httptest.NewRecorder()
engine.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/api/adminStats", nil))
if out.Code != 401 {
t.Fatal("camel action bypassed admin authentication")
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package notify 对应 cron_notify.php:定时扫描 activity_log,补发未成功的订阅消息。
// PHP 版本靠外部 cron 触发;Go 版本在进程内按固定间隔运行。
package notify
import (
"log/slog"
"strings"
"time"
"past/internal/store"
"past/internal/wechat"
)
const (
maxBatch = 20
maxRetries = 3
scanMinutes = 30
)
var actionTypes = []string{
"login", "timeline_create", "album_create", "photo_upload", "diary_create", "diary_comment_add",
"song_add", "checkin_do", "period_add", "period_sex_add", "pet_adopt", "pet_daily", "pet_rename", "pet_all_done",
}
var actionLabels = map[string]string{
"login": "登录了", "timeline_create": "新增了记忆日", "album_create": "创建了新相册", "photo_upload": "上传了新照片",
"diary_create": "写了新的日记", "diary_comment_add": "评论了日记", "song_add": "添加了新歌曲", "checkin_do": "完成了打卡",
"period_add": "记录了姨妈期", "period_sex_add": "记录了亲密时光", "pet_adopt": "领养了新宠物", "pet_daily": "照顾了宠物",
"pet_rename": "给宠物改了名", "pet_all_done": "完成了所有宠物任务",
}
var pageMap = map[string]string{
"login": "pages/index/index", "timeline_create": "pages/timeline/timeline", "album_create": "pages/albums/albums",
"photo_upload": "pages/albums/albums", "diary_create": "pages/diary/diary", "diary_comment_add": "pages/diary/diary",
"song_add": "pages/lovewords/lovewords", "checkin_do": "pages/punch/punch", "period_add": "pages/period/period",
"period_sex_add": "pages/period/period", "pet_adopt": "pages/pet/pet", "pet_daily": "pages/pet/pet",
"pet_rename": "pages/pet/pet", "pet_all_done": "pages/pet/pet",
}
func mbSub(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// Run 按 interval 周期执行一次补发(阻塞,需 go 调用)
func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("cron_notify panic", "err", r)
}
}()
Once(db, wx, false)
}()
time.Sleep(interval)
}
}
// Once 执行一次扫描;force=true 时不限时间且忽略重试上限(对应 --force)
func Once(db *store.DB, wx *wechat.Client, force bool) {
now := time.Now().Format("2006-01-02 15:04:05")
ph := strings.TrimSuffix(strings.Repeat("?,", len(actionTypes)), ",")
args := make([]any, 0, len(actionTypes)+1)
for _, a := range actionTypes {
args = append(args, a)
}
var sql string
if force {
sql = "SELECT a.*, u.nickname, u.couple_id as user_couple_id FROM activity_log a JOIN users u ON a.user_id = u.id WHERE a.action_type IN (" + ph + ") AND a.id NOT IN (SELECT nl.activity_id FROM notify_log nl WHERE nl.status = 'success') AND a.couple_id IS NOT NULL ORDER BY a.created_at DESC LIMIT 20"
} else {
sql = "SELECT a.*, u.nickname, u.couple_id as user_couple_id FROM activity_log a JOIN users u ON a.user_id = u.id WHERE a.action_type IN (" + ph + ") AND a.created_at >= ? AND a.id NOT IN (SELECT nl.activity_id FROM notify_log nl WHERE nl.status = 'success') AND a.couple_id IS NOT NULL ORDER BY a.created_at DESC LIMIT 20"
args = append(args, time.Now().Add(-scanMinutes*time.Minute).Format("2006-01-02 15:04:05"))
}
activities, err := db.Query(sql, args...)
if err != nil {
slog.Error("cron_notify 查询失败", "err", err)
return
}
if len(activities) == 0 {
return
}
processed, success, fail, skip := 0, 0, 0, 0
for _, a := range activities {
activityID, coupleID, userID, actionType := a.Int("id"), a.Int("couple_id"), a.Int("user_id"), a.Str("action_type")
nickname := a.Str("nickname")
if nickname == "" {
nickname = "你的TA"
}
partnerID := partnerUserID(db, userID)
if partnerID == 0 {
skip++
continue
}
cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, partnerID)
retry := int64(0)
if cnt != nil {
retry = cnt.Int("cnt")
}
if retry >= maxRetries && !force {
skip++
continue
}
// buildNotifyData
date := a.Str("created_at")
if len(date) >= 10 {
date = date[:10]
}
desc := a.Str("description")
label, ok := actionLabels[actionType]
if !ok {
label = "有了新操作"
}
page, ok := pageMap[actionType]
if !ok {
page = "pages/index/index"
}
thing1 := nickname + " " + label
thing2 := desc
if thing2 == "" {
thing2 = thing1
}
data := map[string]string{"thing1": mbSub(thing1, 20), "thing2": mbSub(thing2, 20), "date3": date, "thing4": "点击查看详情 💕"}
ok = wx.SendSubscribeMessage(partnerID, "notify", data, page, activityID, userID)
status, errMsg := "success", ""
if !ok {
status, errMsg = "failed", "发送失败(可能用户未订阅或微信接口返回错误)"
}
if retry == 0 {
db.Exec("INSERT INTO notify_log (activity_id, couple_id, user_id, action_type, template_type, to_user_id, status, error_msg, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
activityID, coupleID, userID, actionType, "notify", partnerID, status, errMsg, now)
} else {
db.Exec("UPDATE notify_log SET status = ?, error_msg = ?, sent_at = ? WHERE activity_id = ? AND to_user_id = ?", status, errMsg, now, activityID, partnerID)
}
processed++
if ok {
success++
} else {
fail++
}
}
slog.Info("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip)
}
func partnerUserID(db *store.DB, userID int64) int64 {
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID)
if u == nil || u.Int("couple_id") == 0 {
return 0
}
cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Int("couple_id"))
if cp == nil {
return 0
}
if cp.Int("user1_id") == userID {
return cp.Int("user2_id")
}
return cp.Int("user1_id")
}
+374
View File
@@ -0,0 +1,374 @@
// Package store 封装 MySQL 访问。查询结果统一返回 Row(map),并按列类型转换成与 PHP PDO(mysqlnd)
// 一致的 JSON 表现:整数列 → 数字,DECIMAL/聚合 SUM/AVG → 字符串,日期时间 → 字符串,NULL → null。
package store
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
type DB struct {
*sql.DB
// NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为:
// 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。
NumericAsString bool
}
// Row 一行查询结果,键为列名(或别名)。
type Row map[string]any
func Open(dsn string) (*DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
return &DB{DB: db}, nil
}
// Querier 由 *DB 与 *Tx 共同实现,便于处理函数在事务内外复用。
type Querier interface {
Query(query string, args ...any) ([]Row, error)
QueryOne(query string, args ...any) (Row, error)
Exec(query string, args ...any) (sql.Result, error)
Insert(query string, args ...any) (int64, error)
}
type sqlRunner interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func (d *DB) Query(query string, args ...any) ([]Row, error) {
return queryRows(d.DB, d.NumericAsString, query, args...)
}
// QueryOne 返回第一行;无结果时返回 nil, nil(对应 PDO fetch() 返回 false)。
func (d *DB) QueryOne(query string, args ...any) (Row, error) {
return queryOne(d.DB, d.NumericAsString, query, args...)
}
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
return d.DB.ExecContext(context.Background(), query, args...)
}
// Insert 执行 INSERT 并返回自增 ID。
func (d *DB) Insert(query string, args ...any) (int64, error) {
res, err := d.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// Tx 事务
type Tx struct {
*sql.Tx
numStr bool
}
func (d *DB) Begin() (*Tx, error) {
tx, err := d.DB.Begin()
if err != nil {
return nil, err
}
return &Tx{tx, d.NumericAsString}, nil
}
func (t *Tx) Query(query string, args ...any) ([]Row, error) {
return queryRows(t.Tx, t.numStr, query, args...)
}
func (t *Tx) QueryOne(query string, args ...any) (Row, error) {
return queryOne(t.Tx, t.numStr, query, args...)
}
func (t *Tx) Exec(query string, args ...any) (sql.Result, error) {
return t.Tx.ExecContext(context.Background(), query, args...)
}
func (t *Tx) Insert(query string, args ...any) (int64, error) {
res, err := t.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func queryOne(r sqlRunner, numStr bool, query string, args ...any) (Row, error) {
rows, err := queryRows(r, numStr, query, args...)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
return rows[0], nil
}
func queryRows(r sqlRunner, numStr bool, query string, args ...any) ([]Row, error) {
rs, err := r.QueryContext(context.Background(), query, args...)
if err != nil {
return nil, err
}
defer rs.Close()
cols, err := rs.Columns()
if err != nil {
return nil, err
}
types, err := rs.ColumnTypes()
if err != nil {
return nil, err
}
kinds := make([]colKind, len(cols))
for i, t := range types {
kinds[i] = kindOf(t.DatabaseTypeName())
if numStr {
kinds[i] = kindString
}
}
out := make([]Row, 0, 8)
raw := make([]sql.RawBytes, len(cols))
ptrs := make([]any, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
for rs.Next() {
if err := rs.Scan(ptrs...); err != nil {
return nil, err
}
row := make(Row, len(cols))
for i, c := range cols {
row[c] = convert(kinds[i], raw[i])
}
out = append(out, row)
}
return out, rs.Err()
}
type colKind int
const (
kindString colKind = iota
kindInt
kindFloat
)
func kindOf(dbType string) colKind {
t := strings.ToUpper(dbType)
t = strings.TrimPrefix(t, "UNSIGNED ")
switch t {
case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", "YEAR", "BIT":
return kindInt
case "FLOAT", "DOUBLE":
return kindFloat
default:
// DECIMAL / VARCHAR / TEXT / DATETIME / DATE / JSON ... 在 PDO 下均为字符串
return kindString
}
}
func convert(k colKind, b sql.RawBytes) any {
if b == nil {
return nil
}
switch k {
case kindInt:
if n, err := strconv.ParseInt(string(b), 10, 64); err == nil {
return n
}
return string(b)
case kindFloat:
if f, err := strconv.ParseFloat(string(b), 64); err == nil {
return f
}
return string(b)
default:
return string(b)
}
}
// ---- Row 取值辅助(对应 PHP 弱类型读取)----
func (r Row) Int(key string) int64 {
return ToInt(r[key])
}
func (r Row) Str(key string) string {
return ToStr(r[key])
}
func (r Row) IsNull(key string) bool {
v, ok := r[key]
return !ok || v == nil
}
// ToInt 模拟 PHP intval()
func ToInt(v any) int64 {
switch x := v.(type) {
case nil:
return 0
case int64:
return x
case int:
return int64(x)
case float64:
return int64(x)
case bool:
if x {
return 1
}
return 0
case string:
return atoiPrefix(x)
}
return 0
}
// ToFloat 模拟 PHP floatval()
func ToFloat(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case int64:
return float64(x)
case int:
return float64(x)
case float64:
return x
case bool:
if x {
return 1
}
return 0
case string:
s := strings.TrimSpace(x)
// PHP floatval 取前缀数字
end := 0
for end < len(s) && (s[end] == '-' || s[end] == '+' || s[end] == '.' || (s[end] >= '0' && s[end] <= '9') || s[end] == 'e' || s[end] == 'E') {
end++
}
for end > 0 {
if f, err := strconv.ParseFloat(s[:end], 64); err == nil {
return f
}
end--
}
return 0
}
return 0
}
// ToStr 模拟 PHP 字符串化(数字转字符串,null 转空串)
func ToStr(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case int64:
return strconv.FormatInt(x, 10)
case int:
return strconv.Itoa(x)
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
if x {
return "1"
}
return ""
}
return fmt.Sprint(v)
}
// Truthy 模拟 PHP 的布尔判断(empty 的反义)
func Truthy(v any) bool {
switch x := v.(type) {
case nil:
return false
case string:
return x != "" && x != "0"
case int64:
return x != 0
case int:
return x != 0
case float64:
return x != 0
case bool:
return x
case []any:
return len(x) > 0
case map[string]any:
return len(x) > 0
}
return true
}
func atoiPrefix(s string) int64 {
s = strings.TrimSpace(s)
end := 0
if end < len(s) && (s[end] == '-' || s[end] == '+') {
end++
}
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
if end == 0 {
return 0
}
n, err := strconv.ParseInt(s[:end], 10, 64)
if err != nil {
// 可能是 "12.5" 这种,取整数部分
if f, ferr := strconv.ParseFloat(s, 64); ferr == nil {
return int64(f)
}
return 0
}
return n
}
// ExecNoFK 在一条独占连接上临时关闭外键检查执行 fn(用于 PHP 代码里向 pet_dynamics 写 user_id=0 的场景)。
func (d *DB) ExecNoFK(fn func(q Querier) error) error {
conn, err := d.DB.Conn(context.Background())
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS = 1")
return fn(&connQuerier{conn, d.NumericAsString})
}
type connQuerier struct {
*sql.Conn
numStr bool
}
func (c *connQuerier) Query(query string, args ...any) ([]Row, error) {
return queryRows(c.Conn, c.numStr, query, args...)
}
func (c *connQuerier) QueryOne(query string, args ...any) (Row, error) {
return queryOne(c.Conn, c.numStr, query, args...)
}
func (c *connQuerier) Exec(query string, args ...any) (sql.Result, error) {
return c.Conn.ExecContext(context.Background(), query, args...)
}
func (c *connQuerier) Insert(query string, args ...any) (int64, error) {
res, err := c.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
+165
View File
@@ -0,0 +1,165 @@
package store
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"golang.org/x/crypto/bcrypt"
)
type addColumn struct {
Table, Column, DDL string
}
// InitSchema 对应 install.php 的 initDatabase():建表、补列、默认数据、历史数据修正。
// PHP 版本每次请求都执行;Go 版本只在启动时执行一次。对已迁移的线上库幂等。
func (db *DB) InitSchema() error {
// 会话级设置(SET NAMES / FOREIGN_KEY_CHECKS)必须落在同一条连接上,因此整个初始化独占一条连接
conn, err := db.DB.Conn(context.Background())
if err != nil {
return err
}
defer conn.Close()
d := &connRunner{conn}
if _, err := d.Exec("SET NAMES utf8mb4"); err != nil {
return err
}
// 与 PHP 一致:建表阶段关闭外键检查(表之间存在前向引用),结束后恢复
if _, err := d.Exec("SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer d.Exec("SET FOREIGN_KEY_CHECKS = 1")
for _, stmt := range createTables {
if _, err := d.Exec(stmt); err != nil {
name := strings.Fields(stmt)[5]
return fmt.Errorf("建表 %s 失败: %w", name, err)
}
}
// 默认管理员(密码 admin123)
cnt, err := d.QueryOne("SELECT COUNT(*) as cnt FROM admins")
if err != nil {
return err
}
if cnt.Int("cnt") == 0 {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
// 与 PHP password_hash 一致使用 $2y$ 前缀(算法相同)
phpHash := "$2y$" + strings.TrimPrefix(string(hash), "$2a$")
if _, err := d.Exec("INSERT INTO admins (username, password) VALUES (?, ?)", "admin", phpHash); err != nil {
return err
}
}
// 补列(含 users.phone 的特殊迁移逻辑)
for _, ac := range addColumns {
exists, err := d.columnExists(ac.Table, ac.Column)
if err != nil {
return err
}
if exists {
continue
}
if _, err := d.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", ac.Table, ac.Column, ac.DDL)); err != nil {
return fmt.Errorf("ALTER TABLE %s ADD COLUMN %s: %w", ac.Table, ac.Column, err)
}
if ac.Table == "users" && ac.Column == "phone" {
d.Exec("UPDATE users SET phone = username WHERE phone = '' OR phone IS NULL")
d.Exec("UPDATE users SET phone = CONCAT(id, '_old') WHERE phone = '' OR phone IS NULL")
d.Exec("CREATE UNIQUE INDEX idx_users_phone ON users(phone)")
}
}
// period 相关表 couple_id 允许 NULL:未绑定情侣时按 NULL 保存(对齐 cidOrNil 的设计)。
// 旧库建表时为 NOT NULL,这里做幂等的放宽约束迁移。
for _, t := range []string{"period_records", "period_daily_records", "period_sex_records"} {
if _, err := d.Exec("ALTER TABLE " + t + " MODIFY couple_id INT NULL"); err != nil {
slog.Debug("period 表 couple_id 修改为 NULL 失败", "table", t, "err", err)
}
}
// 确保 username 唯一索引(PHP 用 try/catch 忽略已存在)
if _, err := d.Exec("CREATE UNIQUE INDEX idx_users_username ON users(username)"); err != nil {
slog.Debug("idx_users_username 已存在或创建失败", "err", err)
}
// 修正已有照片 created_by 为 NULL 的记录(补齐为第一个用户)
np, err := d.QueryOne("SELECT COUNT(*) as cnt FROM photos WHERE created_by IS NULL")
if err != nil {
return err
}
if np.Int("cnt") > 0 {
first, _ := d.QueryOne("SELECT id FROM users LIMIT 1")
if first != nil {
d.Exec("UPDATE photos SET created_by = ? WHERE created_by IS NULL", first.Int("id"))
}
}
// 修正已有错误设置值
d.Exec("UPDATE settings SET `value` = ? WHERE `key` = ? AND `value` = ?", "我们past", "site_title", "叁叁肆计划巡演记录视频")
// 默认站点设置
defaults := [][2]string{
{"site_title", "我们past"},
{"site_subtitle", "记录每一个心动瞬间"},
{"theme_color", "#ff6b81"},
{"bg_style", "gradient"},
{"show_timeline", "1"},
{"show_album", "1"},
{"show_diary", "1"},
{"show_anniversary", "1"},
{"show_songs", "1"},
{"show_checkin", "1"},
}
for _, kv := range defaults {
if _, err := d.Exec("INSERT IGNORE INTO settings (`key`, `value`) VALUES (?, ?)", kv[0], kv[1]); err != nil {
return err
}
}
// 补全历史数据:last_login_at 为 NULL 的用户设为 created_at
d.Exec("UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL")
// 爱爱记录 measure 字段 '无性行为' → '无措施'
d.Exec("UPDATE period_sex_records SET measure = '无措施' WHERE measure = '无性行为'")
// index.php 的 diary_list/diary_detail 引用了 diary_likes / diary_favorites,但 install.php 从未建表
// (线上库应已存在)。这里仅在缺失时补建,避免全新环境 500;已存在则不做任何改动。
for _, t := range []string{"diary_likes", "diary_favorites"} {
if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS ` + t + ` (
id INT AUTO_INCREMENT PRIMARY KEY,
diary_id INT NOT NULL,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_diary_user (diary_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); err != nil {
return err
}
}
return nil
}
// connRunner 让单条 *sql.Conn 具备 Query/QueryOne/Exec 能力
type connRunner struct{ *sql.Conn }
func (c *connRunner) Query(query string, args ...any) ([]Row, error) {
return queryRows(c.Conn, false, query, args...)
}
func (c *connRunner) QueryOne(query string, args ...any) (Row, error) {
return queryOne(c.Conn, false, query, args...)
}
func (c *connRunner) Exec(query string, args ...any) (sql.Result, error) {
return c.Conn.ExecContext(context.Background(), query, args...)
}
func (d *connRunner) columnExists(table, column string) (bool, error) {
rows, err := d.Query("SHOW COLUMNS FROM "+table+" LIKE ?", column)
if err != nil {
return false, err
}
return len(rows) > 0, nil
}
+421
View File
@@ -0,0 +1,421 @@
package store
// 本文件由 backend/install.php 的 SQL 语句自动提取生成,表结构与线上 PHP 版本保持一致。
// 请勿手工改动字段定义;如需新增字段,追加到 addColumns 中。
// createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。
var createTables = []string{
`CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(20) UNIQUE,
openid VARCHAR(100) UNIQUE,
phone VARCHAR(50) DEFAULT '',
username VARCHAR(100) DEFAULT '',
password VARCHAR(255) DEFAULT '',
nickname VARCHAR(100) DEFAULT '',
avatar VARCHAR(500) DEFAULT '',
gender VARCHAR(10) DEFAULT '',
couple_id INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS couples (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_space_id VARCHAR(50) UNIQUE,
user1_id INT NOT NULL,
user2_id INT NOT NULL,
love_start_date VARCHAR(20) DEFAULT '',
love_story TEXT,
status VARCHAR(20) DEFAULT 'active',
data_deleted TINYINT DEFAULT 0,
period_visible TINYINT DEFAULT 0 COMMENT '男方是否可见女生日历 0-不可见 1-可见',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS invitations (
id INT AUTO_INCREMENT PRIMARY KEY,
from_user_id INT NOT NULL,
to_user_id INT DEFAULT NULL,
to_phone VARCHAR(50) DEFAULT '',
status VARCHAR(20) DEFAULT 'pending',
message TEXT,
invite_code VARCHAR(100) DEFAULT '',
type VARCHAR(20) DEFAULT 'phone',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS unbind_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
requester_id INT NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME DEFAULT NULL,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS invite_links (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
used INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS timeline (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
event_date VARCHAR(20) NOT NULL,
location VARCHAR(200) DEFAULT '',
images TEXT,
icon VARCHAR(20) DEFAULT '❤️',
sort_order INT DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS albums (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
cover VARCHAR(500) DEFAULT '',
sort_order INT DEFAULT 0,
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
location VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS photos (
id INT AUTO_INCREMENT PRIMARY KEY,
album_id INT NOT NULL,
path VARCHAR(500) NOT NULL,
description TEXT,
taken_at VARCHAR(50) DEFAULT '',
location VARCHAR(200) DEFAULT '',
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
activity VARCHAR(200) DEFAULT '',
batch_id VARCHAR(50) DEFAULT '',
sort_order INT DEFAULT 0,
created_by INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS diaries (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
mood VARCHAR(20) DEFAULT '😊',
weather VARCHAR(20) DEFAULT '☀️',
diary_date VARCHAR(20) NOT NULL,
images TEXT,
is_anniversary INT DEFAULT 0,
region VARCHAR(100) DEFAULT '' COMMENT '发布地区',
lat DECIMAL(10,6) DEFAULT 0 COMMENT '纬度',
lng DECIMAL(10,6) DEFAULT 0 COMMENT '经度',
location VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS songs (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
song_name VARCHAR(255) NOT NULL,
artist VARCHAR(255) NOT NULL,
note VARCHAR(120) DEFAULT '',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS checkins (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
icon VARCHAR(20) DEFAULT '✅',
type VARCHAR(10) DEFAULT 'dual',
frequency VARCHAR(10) DEFAULT 'daily',
is_completed INT DEFAULT 0,
completed_by_me INT DEFAULT 0,
completed_by_partner INT DEFAULT 0,
my_checkin_time DATETIME,
partner_checkin_time DATETIME,
my_streak INT DEFAULT 0,
partner_streak INT DEFAULT 0,
my_photo VARCHAR(500) DEFAULT '',
partner_photo VARCHAR(500) DEFAULT '',
my_note VARCHAR(200) DEFAULT '',
partner_note VARCHAR(200) DEFAULT '',
completed_at DATETIME,
sort_order INT DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS checkin_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
checkin_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
photo VARCHAR(500) DEFAULT '',
note VARCHAR(200) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
cycle_length INT DEFAULT 28,
period_length INT DEFAULT 5,
symptoms VARCHAR(500) DEFAULT '',
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_daily_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
record_date DATE NOT NULL,
period ENUM('0','1','2','3') DEFAULT '0' COMMENT '0-无 1-少量 2-中量 3-大量',
sex ENUM('0','1') DEFAULT '0' COMMENT '0-无 1-爱爱',
symptoms VARCHAR(200) DEFAULT '' COMMENT '症状,逗号分隔',
mood VARCHAR(100) DEFAULT '' COMMENT '心情',
discharge VARCHAR(100) DEFAULT '' COMMENT '白带',
temperature DECIMAL(4,1) DEFAULT NULL COMMENT '体温',
weight DECIMAL(5,1) DEFAULT NULL COMMENT '体重',
note TEXT COMMENT '备注',
period_detail TEXT COMMENT '经期详情JSON(量/颜色/疼痛)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_couple (record_date, couple_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_sex_records (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL,
user_id INT NOT NULL,
record_date DATE NOT NULL,
record_time TIME DEFAULT NULL COMMENT '记录时间,精确到分钟',
measure VARCHAR(100) DEFAULT '' COMMENT '措施类型',
measure_custom VARCHAR(200) DEFAULT '' COMMENT '其他措施自定义内容',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_date_couple (record_date, couple_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS settings (
` + "`" + `key` + "`" + ` VARCHAR(255) PRIMARY KEY,
` + "`" + `value` + "`" + ` TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS diary_comments (
id INT AUTO_INCREMENT PRIMARY KEY,
diary_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
parent_id INT DEFAULT 0 COMMENT '回复的评论ID,0为顶级评论',
reply_to_user_id INT DEFAULT NULL COMMENT '被回复的用户ID',
reply_to_username VARCHAR(100) DEFAULT '' COMMENT '被回复的用户昵称',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (diary_id) REFERENCES diaries(id) ON DELETE CASCADE,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_diary (diary_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscribe_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
template_id VARCHAR(100) NOT NULL,
template_type VARCHAR(50) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_template (user_id, template_type),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action_type VARCHAR(50) NOT NULL,
description VARCHAR(500) NOT NULL,
detail TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_couple_time (couple_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS user_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(128) NOT NULL UNIQUE,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS notify_log (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_id INT NOT NULL,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action_type VARCHAR(50) NOT NULL,
template_type VARCHAR(50) NOT NULL,
to_user_id INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT '',
error_msg TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL,
UNIQUE KEY uk_activity (activity_id, to_user_id),
INDEX idx_status_time (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pets (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL DEFAULT '小狗狗',
breed VARCHAR(20) NOT NULL DEFAULT 'golden' COMMENT 'golden/husky/corgi/shiba',
gender TINYINT NOT NULL DEFAULT 1 COMMENT '1-男 2-女',
stage INT NOT NULL DEFAULT 1 COMMENT '1幼犬 2少年 3青年 4成年 5终极',
level INT NOT NULL DEFAULT 1,
exp INT NOT NULL DEFAULT 0,
happiness INT NOT NULL DEFAULT 100,
hunger INT NOT NULL DEFAULT 100,
energy INT NOT NULL DEFAULT 100,
cleanliness INT NOT NULL DEFAULT 100,
adopted_at DATE NOT NULL,
created_by INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_dailies (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
date DATE NOT NULL,
feed_u1 TINYINT DEFAULT 0,
feed_u2 TINYINT DEFAULT 0,
water_u1 TINYINT DEFAULT 0,
water_u2 TINYINT DEFAULT 0,
walk_u1 TINYINT DEFAULT 0,
walk_u2 TINYINT DEFAULT 0,
bath_u1 TINYINT DEFAULT 0,
bath_u2 TINYINT DEFAULT 0,
sleep_u1 TINYINT DEFAULT 0,
sleep_u2 TINYINT DEFAULT 0,
play_u1 TINYINT DEFAULT 0,
play_u2 TINYINT DEFAULT 0,
all_done TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_couple_date (couple_id, date),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
achievement_key VARCHAR(50) NOT NULL,
unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_couple_ach (couple_id, achievement_key),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_items (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
item_key VARCHAR(50) NOT NULL,
item_type VARCHAR(20) NOT NULL COMMENT 'accessory/collar/scarf/background/consumable',
quantity INT DEFAULT 1,
equipped TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_couple (couple_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS pet_dynamics (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
action VARCHAR(30) NOT NULL,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_couple_time (couple_id, created_at DESC),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。
var addColumns = []addColumn{
{"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"},
{"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"},
{"checkins", "completed_by_me", "INT DEFAULT 0"},
{"checkins", "completed_by_partner", "INT DEFAULT 0"},
{"checkins", "my_checkin_time", "DATETIME"},
{"checkins", "partner_checkin_time", "DATETIME"},
{"checkins", "my_streak", "INT DEFAULT 0"},
{"checkins", "partner_streak", "INT DEFAULT 0"},
{"checkins", "my_photo", "VARCHAR(500) DEFAULT ''"},
{"checkins", "partner_photo", "VARCHAR(500) DEFAULT ''"},
{"checkins", "my_note", "VARCHAR(200) DEFAULT ''"},
{"checkins", "partner_note", "VARCHAR(200) DEFAULT ''"},
{"period_daily_records", "period_detail", "TEXT COMMENT '经期详情JSON(量/颜色/疼痛)'"},
{"users", "openid", "VARCHAR(100) UNIQUE"},
{"users", "phone", "VARCHAR(50) DEFAULT ''"},
{"users", "gender", "VARCHAR(10) DEFAULT ''"},
{"users", "period_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '姨姨:是否对伴侣可见'"},
{"users", "age", "INT DEFAULT 0"},
{"users", "birthday", "VARCHAR(20) DEFAULT ''"},
{"users", "last_login_at", "DATETIME DEFAULT NULL"},
{"users", "region", "VARCHAR(100) DEFAULT '' COMMENT '地区,如云南楚雄'"},
{"users", "location", "VARCHAR(200) DEFAULT '' COMMENT '模糊定位,如纬度,经度'"},
{"users", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度'"},
{"users", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度'"},
{"users", "last_login_location", "VARCHAR(200) DEFAULT '' COMMENT '最近一次登录地址'"},
{"users", "ip_geo_date", "DATE DEFAULT NULL COMMENT '最后一次 IP 定位日期(每日限流)'"},
{"users", "last_ip", "VARCHAR(64) DEFAULT '' COMMENT '最后一次 IP 定位的客户端 IP 地址'"},
{"invitations", "to_phone", "VARCHAR(50) DEFAULT ''"},
{"invitations", "invite_code", "VARCHAR(100) DEFAULT ''"},
{"invitations", "type", "VARCHAR(20) DEFAULT 'phone'"},
{"couples", "couple_space_id", "VARCHAR(50) UNIQUE"},
{"couples", "period_source_user_id", "INT DEFAULT NULL COMMENT '绑定前姨姨记录选定的来源用户'"},
{"couples", "data_deleted", "TINYINT DEFAULT 0"},
{"photos", "taken_at", "VARCHAR(50) DEFAULT ''"},
{"photos", "location", "VARCHAR(200) DEFAULT ''"},
{"photos", "batch_id", "VARCHAR(50) DEFAULT ''"},
{"photos", "activity", "VARCHAR(200) DEFAULT ''"},
{"photos", "created_by", "INT DEFAULT NULL"},
{"diaries", "region", "VARCHAR(100) DEFAULT '' COMMENT '发布地区' AFTER is_anniversary"},
{"diaries", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER region"},
{"diaries", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"diaries", "location", "VARCHAR(200) DEFAULT '' COMMENT '发布地理位置名称' AFTER lng"},
{"albums", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER sort_order"},
{"albums", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"albums", "location", "VARCHAR(200) DEFAULT '' COMMENT '相册地理位置名称' AFTER lng"},
{"photos", "lat", "DECIMAL(10,6) DEFAULT 0 COMMENT '纬度' AFTER location"},
{"photos", "lng", "DECIMAL(10,6) DEFAULT 0 COMMENT '经度' AFTER lat"},
{"timeline", "location", "VARCHAR(200) DEFAULT '' AFTER event_date"},
{"timeline", "repeat_interval", "INT DEFAULT 0 AFTER location"},
{"timeline", "repeat_unit", "VARCHAR(10) DEFAULT '' AFTER repeat_interval"},
{"timeline", "bg_image", "VARCHAR(500) DEFAULT '' AFTER repeat_unit"},
{"couples", "data_deleted", "TINYINT(1) DEFAULT 0 COMMENT '0=数据保留,1=数据已删除'"},
}
+194
View File
@@ -0,0 +1,194 @@
// Package upload 对应 config.php 中的 handleUpload() 与路径辅助函数。
package upload
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// 上传错误码(对应 config.php UPLOAD_ERR_* 常量)
const (
ErrNoFileReceived = 1001
ErrFileFieldMissing = 1002
ErrHandleFailed = 1003
ErrRawBodySave = 1004
ErrAlbumNotFound = 1005
ErrNotLoggedIn = 1006
ErrNotBound = 1007
ErrMethodNotAllowed = 1008
ErrPartnerInvalid = 1009
ErrFileTooLarge = 1010
)
var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true}
// ImageChecker 图片内容安全检测(由 wechat 包实现)
type ImageChecker interface {
CheckImage(filePath, openid string) (bool, string)
}
type Uploader struct {
// Dir 上传根目录(UPLOAD_DIR)
Dir string
MaxSize int64
Checker ImageChecker
}
// Result 对应 handleUpload 返回的 ['path' => ...] 或 ['error' => ...]
type Result struct {
Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg
Error string
}
// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId}
func AvatarPath(userID string) string { return "avatars/" + userID }
// CouplePath 对应 couplePath($spaceId, $type)
func CouplePath(spaceID, typ string) string {
if typ != "" {
return "couples/" + spaceID + "/" + typ
}
return "couples/" + spaceID
}
// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type}
func UserFilePath(userID int64, typ string) string {
p := fmt.Sprintf("users/%d", userID)
if typ != "" {
p += "/" + typ
}
return p
}
// NewFilename 对应 date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . ext
func NewFilename(ext string) string {
b := make([]byte, 4)
rand.Read(b)
return time.Now().Format("20060102150405") + "_" + hex.EncodeToString(b) + "." + ext
}
// Ext 取小写扩展名(不含点)
func Ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// Handle 对应 handleUpload($file, $subDir, $prefixPath)
func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result {
if fh == nil {
return Result{Error: "未接收到文件"}
}
f, err := fh.Open()
if err != nil {
return Result{Error: "未接收到文件"}
}
defer f.Close()
return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath)
}
// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result {
if size > u.MaxSize {
return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)}
}
ext := Ext(filename)
if !allowedExts[ext] {
return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"}
}
// MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝
head := make([]byte, 512)
n, _ := io.ReadFull(f, head)
// finfo 只返回主类型(不带 "; charset=...")
mime := http.DetectContentType(head[:n])
if i := strings.Index(mime, ";"); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
if !strings.HasPrefix(mime, "image/") && mime != "application/octet-stream" {
return Result{Error: "文件MIME类型不匹配: " + mime}
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return Result{Error: "文件读取失败"}
}
return u.save(f, "", ext, subDir, prefixPath)
}
// HandleBytes 对 HandleReader 的便捷封装(原始请求体 → 伪装成 $_FILES 走完整校验)
func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefixPath string) Result {
return u.HandleReader(bytes.NewReader(data), filename, int64(len(data)), subDir, prefixPath)
}
// SaveRaw 保存原始二进制数据(photo_upload / set_bg_image 的 RAW BODY 兜底路径),不做扩展名/MIME 校验。
// namePrefix 为文件名前缀(如 "bg_")。
func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefixPath string) Result {
return u.save(bytes.NewReader(data), namePrefix, ext, subDir, prefixPath)
}
func (u *Uploader) save(src io.Reader, namePrefix, ext, subDir, prefixPath string) Result {
rel := "uploads/"
dir := u.Dir
if prefixPath != "" {
rel += prefixPath + "/"
dir = filepath.Join(dir, filepath.FromSlash(prefixPath))
}
if subDir != "" {
rel += subDir + "/"
dir = filepath.Join(dir, filepath.FromSlash(subDir))
}
if err := os.MkdirAll(dir, 0755); err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
name := namePrefix + NewFilename(ext)
dest := filepath.Join(dir, name)
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
if _, err := io.Copy(out, src); err != nil {
out.Close()
os.Remove(dest)
return Result{Error: "文件保存失败。系统错误: " + err.Error()}
}
out.Close()
slog.Info("upload saved", "path", rel+name)
if u.Checker != nil {
if pass, msg := u.Checker.CheckImage(dest, ""); !pass {
os.Remove(dest)
return Result{Error: msg}
}
}
return Result{Path: rel + name}
}
// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。
func (u *Uploader) Remove(relPath string) {
if relPath == "" || strings.Contains(relPath, "..") {
return
}
p := strings.TrimPrefix(relPath, "uploads/")
if p == relPath {
return
}
os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p)))
}
// Abs 返回相对路径对应的绝对路径
func (u *Uploader) Abs(relPath string) string {
p := strings.TrimPrefix(relPath, "uploads/")
return filepath.Join(u.Dir, filepath.FromSlash(p))
}
// Exists 文件是否存在
func (u *Uploader) Exists(relPath string) bool {
st, err := os.Stat(u.Abs(relPath))
return err == nil && !st.IsDir()
}
+317
View File
@@ -0,0 +1,317 @@
// Package wechat 对应 config.php 中的微信相关函数:access_token 缓存、内容安全检测、订阅消息、jscode2session。
package wechat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"time"
"past/internal/store"
)
type Client struct {
AppID string
Secret string
NotifyTemplateID string
DataDir string // 用于 access_token.cache
DB *store.DB
http *http.Client
mu sync.Mutex
}
func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client {
return &Client{
AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, DataDir: dataDir, DB: db,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// Templates 对应 WECHAT_SUBSCRIBE_TEMPLATES
func (c *Client) Templates() map[string]string {
return map[string]string{"notify": c.NotifyTemplateID}
}
func (c *Client) cacheFile() string { return filepath.Join(c.DataDir, "data", "access_token.cache") }
// AccessToken 获取 access_token;文件缓存 115 分钟(按修改时间判断),与 PHP 一致。
func (c *Client) AccessToken() string {
c.mu.Lock()
defer c.mu.Unlock()
cf := c.cacheFile()
if st, err := os.Stat(cf); err == nil && time.Since(st.ModTime()) < 6900*time.Second {
if b, err := os.ReadFile(cf); err == nil && len(bytes.TrimSpace(b)) > 0 {
return string(bytes.TrimSpace(b))
}
}
u := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
resp, err := c.http.Get(u)
if err != nil {
return ""
}
defer resp.Body.Close()
var data struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.AccessToken == "" {
return ""
}
os.MkdirAll(filepath.Dir(cf), 0755)
os.WriteFile(cf, []byte(data.AccessToken), 0644)
return data.AccessToken
}
func (c *Client) clearTokenCache() {
c.mu.Lock()
os.Remove(c.cacheFile())
c.mu.Unlock()
}
// postJSON 对应 wechatPost:非 200 返回 nil
func (c *Client) postJSON(u string, body []byte, timeout time.Duration) map[string]any {
cl := &http.Client{Timeout: timeout}
resp, err := cl.Post(u, "application/json", bytes.NewReader(body))
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil
}
return out
}
func errcode(m map[string]any) int64 {
if m == nil {
return -1
}
if v, ok := m["errcode"]; ok {
return store.ToInt(v)
}
return -1
}
// CheckText 文本内容安全检测(msg_sec_check)。返回 (pass, errmsg)。
// 与 PHP 一致:token 获取失败 / 网络异常 / 非 87014 错误均放行。
func (c *Client) CheckText(text, openid string) (bool, string) {
if bytes.TrimSpace([]byte(text)) == nil || len(bytes.TrimSpace([]byte(text))) == 0 {
return true, ""
}
tok := c.AccessToken()
if tok == "" {
return true, ""
}
body, _ := marshalUnescaped(map[string]any{
"version": 2, "openid": openid, "scene": 2, "content": text,
})
res := c.postJSON("https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+tok, body, 10*time.Second)
if res == nil {
return true, ""
}
if errcode(res) == 87014 {
return false, "所发布内容含违规信息"
}
return true, ""
}
// CheckImage 图片内容安全检测(media_check_async)。PHP 实现只是异步提交,结果永远放行。
func (c *Client) CheckImage(filePath, openid string) (bool, string) {
st, err := os.Stat(filePath)
if err != nil || st.IsDir() {
return true, ""
}
tok := c.AccessToken()
if tok == "" {
return true, ""
}
f, err := os.Open(filePath)
if err != nil {
return true, ""
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("media", filepath.Base(filePath))
io.Copy(fw, f)
mw.WriteField("scene", "2")
mw.WriteField("openid", openid)
mw.Close()
req, _ := http.NewRequest("POST", "https://api.weixin.qq.com/wxa/media_check_async?access_token="+tok, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := c.http.Do(req)
if err == nil {
resp.Body.Close()
}
return true, ""
}
// Jscode2Session 通过 code 换取 openid。返回 (openid, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。
func (c *Client) Jscode2Session(code string) (openid string, wxErr string, available bool) {
u := "https://api.weixin.qq.com/sns/jscode2session?appid=" + c.AppID + "&secret=" + c.Secret + "&js_code=" + url.QueryEscape(code) + "&grant_type=authorization_code"
resp, err := c.http.Get(u)
if err != nil {
return "", "", false
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if len(b) == 0 {
return "", "", false
}
var data map[string]any
if err := json.Unmarshal(b, &data); err != nil {
return "", "", true
}
if ec, ok := data["errcode"]; ok && store.ToInt(ec) != 0 {
msg := store.ToStr(data["errmsg"])
if msg == "" {
msg = "未知错误"
}
return "", msg, true
}
return store.ToStr(data["openid"]), "", true
}
// SendSubscribeMessage 对应 sendSubscribeMessage()。
func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data map[string]string, page string, activityID int64, userID int64) bool {
templateID, ok := c.Templates()[templateType]
if !ok {
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "模板类型不存在")
}
return false
}
db := c.DB
sub, _ := db.QueryOne("SELECT id FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType)
if sub == nil {
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "用户未订阅")
}
return false
}
user, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", toUserID)
if user == nil || user.Str("openid") == "" {
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", "用户无openid")
}
return false
}
msgData := map[string]any{}
for k, v := range data {
msgData[k] = map[string]any{"value": v}
}
if page == "" {
page = "pages/index/index"
}
post, _ := marshalUnescaped(map[string]any{
"touser": user.Str("openid"), "template_id": templateID, "page": page,
"data": msgData, "miniprogram_state": "formal", "lang": "zh_CN",
})
lastError := ""
for attempt := 1; attempt <= 2; attempt++ {
tok := c.AccessToken()
if tok == "" {
lastError = "获取access_token失败"
if attempt < 2 {
c.clearTokenCache()
}
continue
}
cl := &http.Client{Timeout: 5 * time.Second}
resp, err := cl.Post("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token="+tok, "application/json", bytes.NewReader(post))
if err != nil || resp.StatusCode != 200 {
code := 0
if resp != nil {
code = resp.StatusCode
resp.Body.Close()
}
lastError = fmt.Sprintf("HTTP %d", code)
if attempt < 2 {
c.clearTokenCache()
time.Sleep(200 * time.Millisecond)
}
continue
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
ec := errcode(result)
if ec == 0 {
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "success", "")
}
return true
}
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
if ec == 40001 && attempt < 2 {
c.clearTokenCache()
continue
}
// 43101 可能是「一次性订阅额度已用尽」,并非用户主动关闭订阅;
// 此时删除记录会导致前端设置页显示为未勾选,误导用户。仅在 openid 无效(40003)时清理。
if ec == 40003 {
db.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType)
}
break
}
if activityID != 0 {
c.LogNotifyResult(activityID, toUserID, userID, templateType, "failed", lastError)
}
return false
}
// LogNotifyResult 对应 logNotifyResult()
func (c *Client) LogNotifyResult(activityID, toUserID, userID int64, templateType, status, errorMsg string) {
db := c.DB
now := time.Now().Format("2006-01-02 15:04:05")
lookup := userID
if lookup == 0 {
lookup = toUserID
}
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", lookup)
var coupleID any
if u != nil {
coupleID = u["couple_id"]
}
actionType := ""
if activityID != 0 {
a, _ := db.QueryOne("SELECT action_type FROM activity_log WHERE id = ?", activityID)
if a != nil {
actionType = a.Str("action_type")
}
}
cnt, _ := db.QueryOne("SELECT COUNT(*) as cnt FROM notify_log WHERE activity_id = ? AND to_user_id = ?", activityID, toUserID)
var err error
if cnt != nil && cnt.Int("cnt") > 0 {
_, err = db.Exec("UPDATE notify_log SET status = ?, error_msg = ?, sent_at = ? WHERE activity_id = ? AND to_user_id = ?", status, errorMsg, now, activityID, toUserID)
} else {
_, err = db.Exec("INSERT INTO notify_log (activity_id, couple_id, user_id, action_type, template_type, to_user_id, status, error_msg, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now)
}
if err != nil {
slog.Error("logNotifyResult error", "err", err)
}
}
func marshalUnescaped(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我们past · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7630
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.3.0",
"lucide-react": "^1.46.0",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"shadcn": "^4.21.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/node": "^24.13.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">💗</text>
</svg>

After

Width:  |  Height:  |  Size: 194 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+54
View File
@@ -0,0 +1,54 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { api } from "@/lib/api"
import Layout from "@/components/Layout"
import Login from "@/pages/Login"
import Dashboard from "@/pages/Dashboard"
import UsersPage from "@/pages/Users"
import CouplesPage from "@/pages/Couples"
import ContentsPage from "@/pages/Contents"
import BackgroundsPage from "@/pages/Backgrounds"
import UnbindPage from "@/pages/Unbind"
import PetsPage from "@/pages/Pets"
import SettingsPage from "@/pages/Settings"
// 登录态:admin_check_auth(Cookie 会话)
export function useAuth() {
return useQuery({
queryKey: ["auth"],
// admin_check_auth 与 PHP 一致:未登录时直接 401,这里把它当作 logged_in=false
queryFn: () => api<{ logged_in: boolean; username?: string }>("adminCheckAuth", null, "GET").catch(() => ({ logged_in: false })),
staleTime: 60_000,
})
}
export default function App() {
const auth = useAuth()
const loc = useLocation()
if (auth.isPending) {
return <div className="flex min-h-screen items-center justify-center text-muted-foreground">加载中…</div>
}
const loggedIn = !!auth.data?.logged_in
return (
<Routes>
<Route path="/login" element={loggedIn ? <Navigate to="/" replace /> : <Login />} />
{loggedIn ? (
<Route element={<Layout username={localStorage.getItem("admin_username") || "admin"} />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<UsersPage />} />
<Route path="couples" element={<CouplesPage />} />
<Route path="contents" element={<ContentsPage />} />
<Route path="backgrounds" element={<BackgroundsPage />} />
<Route path="unbind" element={<UnbindPage />} />
<Route path="pets" element={<PetsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
) : (
<Route path="*" element={<Navigate to="/login" replace state={{ from: loc.pathname }} />} />
)}
</Routes>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,176 @@
import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { api, fmtTime, parseImages, daysSince, BREED, BREED_EMOJI, STAGE, STAGE_COLOR } from "@/lib/api"
import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview"
import { Empty, Loading, ErrorBox, RecordItem } from "@/components/common"
import { DeleteContentDialog, type DeleteContentTarget } from "@/components/UserDialogs"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
export function CoupleDetailDialog({ coupleId, onClose }: { coupleId: number | null; onClose: () => void }) {
const qc = useQueryClient()
const q = useQuery({
queryKey: ["admin_couple_detail", coupleId],
queryFn: () => api("adminCoupleDetail", { couple_id: coupleId }, "GET"),
enabled: !!coupleId,
})
const [del, setDel] = useState<DeleteContentTarget | null>(null)
const d = q.data
const users: any[] = d?.users || []
const delBtn = (type: string, id: number, label: string) => (
<Button size="xs" variant="destructive" className="ml-auto shrink-0" onClick={(e) => { e.stopPropagation(); setDel({ type, id, coupleId: coupleId!, label }) }}>🗑️ 删除</Button>
)
return (
<Dialog open={!!coupleId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>💕 情侣详情</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-wrap items-center gap-1.5">
<span>情侣成员:</span>
{users.length ? users.map((u) => (
<span key={u.id} className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-pink-400 to-violet-400 px-2.5 py-0.5 text-xs text-white">
<Avatar path={u.avatar} name={u.nickname || u.username} size={18} />
{u.nickname || u.username || "?"} (ID:{u.user_id || "-"})
</span>
)) : <span className="text-muted-foreground">已解除绑定</span>}
</div>
</DialogDescription>
</DialogHeader>
{q.isPending ? <Loading /> : q.isError ? <ErrorBox error={q.error} /> : (
<Tabs defaultValue="timeline">
<TabsList className="flex w-full flex-wrap justify-start">
<TabsTrigger value="timeline">📅 记忆日</TabsTrigger>
<TabsTrigger value="diary">📝 日记</TabsTrigger>
<TabsTrigger value="album">📷 相册</TabsTrigger>
<TabsTrigger value="photos">🖼️ 照片</TabsTrigger>
<TabsTrigger value="songs">🎵 歌曲</TabsTrigger>
<TabsTrigger value="checkin">✅ 打卡</TabsTrigger>
<TabsTrigger value="activity">📋 操作记录</TabsTrigger>
<TabsTrigger value="pet">🐾 宠物</TabsTrigger>
</TabsList>
<TabsContent value="timeline">
{!d.timeline?.length ? <Empty icon="📅" text="暂无" /> : d.timeline.map((t: any) => (
<RecordItem key={t.id} icon="📅" right={<><span className="text-xs text-muted-foreground">{t.event_date}</span>{delBtn("timeline", t.id, t.title)}</>}
footer={<div className="flex flex-wrap gap-1.5"><Thumb path={t.bg_image} /><ThumbGallery paths={parseImages(t.images)} /></div>}>
<span className="font-medium">{t.title}</span>{t.location && <span className="text-muted-foreground"> 📍{t.location}</span>}
{t.content && <div className="text-xs text-muted-foreground">{t.content}</div>}
</RecordItem>
))}
</TabsContent>
<TabsContent value="diary">
{!d.diaries?.length ? <Empty icon="📝" text="暂无" /> : d.diaries.map((x: any) => (
<RecordItem key={x.id} icon="📝" right={<><span className="text-xs text-muted-foreground">{x.diary_date}</span>{delBtn("diary", x.id, x.title || "日记")}</>} footer={<ThumbGallery paths={parseImages(x.images)} />}>
{x.title || "一篇日记"}
</RecordItem>
))}
</TabsContent>
<TabsContent value="album">
{!d.albums?.length ? <Empty icon="📷" text="暂无" /> : d.albums.map((a: any) => (
<RecordItem key={a.id} icon="📷" right={delBtn("album", a.id, a.title)}>
<div className="flex items-center gap-2"><Thumb path={a.cover} className="size-10" /><div>{a.title}<div className="text-xs text-muted-foreground">{a.description}</div></div></div>
</RecordItem>
))}
</TabsContent>
<TabsContent value="photos">
{!d.photos?.length ? <Empty icon="🖼️" text="暂无照片" /> : (
<>
<div className="flex flex-wrap gap-1.5 py-2">{d.photos.map((p: any) => <Thumb key={p.id} path={p.path} title={p.description} className="size-20" />)}</div>
<div className="text-center text-xs text-muted-foreground">共 {d.photos.length} 张照片</div>
</>
)}
</TabsContent>
<TabsContent value="songs">
{!d.songs?.length ? <Empty icon="🎵" text="暂无" /> : d.songs.map((s: any) => (
<RecordItem key={s.id} icon="🎵" right={delBtn("song", s.id, s.song_name)}>
{s.song_name} — {s.artist}{s.note && <div className="text-xs text-muted-foreground">📝 {s.note}</div>}
</RecordItem>
))}
</TabsContent>
<TabsContent value="checkin">
{!d.checkins?.length ? <Empty icon="✅" text="暂无" /> : d.checkins.map((c: any) => (
<RecordItem key={c.id} icon="✅" right={delBtn("checkin", c.id, c.title || "打卡")}>
{c.title || "打卡"} <Badge variant="secondary" className="ml-1">{c.type === "single" || c.type === "first" || c.type === "her" ? "单人" : "双人"}</Badge>
<div className="text-xs text-muted-foreground">我{c.completed_by_me ? "✅" : "⬜"} TA{c.completed_by_partner ? "✅" : "⬜"}</div>
</RecordItem>
))}
</TabsContent>
<TabsContent value="activity"><ActivityTab coupleId={coupleId!} /></TabsContent>
<TabsContent value="pet"><PetPanel pet={d.pet} /></TabsContent>
</Tabs>
)}
<DeleteContentDialog target={del} onClose={() => setDel(null)} onDone={() => { qc.invalidateQueries({ queryKey: ["admin_couple_detail", coupleId] }); qc.invalidateQueries({ queryKey: ["admin_contents"] }) }} />
</DialogContent>
</Dialog>
)
}
function ActivityTab({ coupleId }: { coupleId: number }) {
const q = useQuery({ queryKey: ["admin_activity_log", coupleId], queryFn: () => api<any[]>("adminActivityLog", { couple_id: coupleId }, "GET") })
if (q.isPending) return <Loading />
if (q.isError) return <ErrorBox error={q.error} />
if (!q.data.length) return <Empty icon="📋" text="暂无操作记录" />
return (
<div>
{q.data.map((a) => (
<RecordItem key={a.id} icon="👤" right={<span className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(a.created_at)}</span>}>
<strong className={a.gender === "male" ? "text-sky-500" : "text-rose-400"}>{a.nickname || "?"}</strong> {a.description}
</RecordItem>
))}
</div>
)
}
function Bar({ label, icon, val, color }: { label: string; icon: string; val: number; color: string }) {
const pct = Math.min(100, Math.max(0, val))
return (
<div className="my-1.5">
<div className="mb-0.5 flex justify-between text-xs"><span>{icon} {label}</span><span>{val}</span></div>
<div className="h-1.5 overflow-hidden rounded bg-muted"><div className="h-full rounded" style={{ width: `${pct}%`, background: color }} /></div>
</div>
)
}
// 宠物面板(情侣详情 / admin_couple_detail 返回的 pet 含 dailies/achievements 等)
export function PetPanel({ pet }: { pet: any }) {
if (!pet) return <Empty icon="🐾" text="该情侣尚未领养宠物" />
const stageClr = STAGE_COLOR[pet.stage - 1] || STAGE_COLOR[0]
const allDone = (d: any) => ["feed", "water", "walk", "bath", "sleep", "play"].every((k) => d[`${k}_u1`] && d[`${k}_u2`])
return (
<div className="space-y-4">
<div className="py-2 text-center">
<div className="text-6xl leading-tight">{BREED_EMOJI[pet.breed] || "🐕"}</div>
<div className="mt-2 text-xl font-bold">{pet.name}</div>
<div className="text-sm text-muted-foreground">{BREED[pet.breed] || pet.breed} · {pet.gender == 1 ? "♂ 男孩" : "♀ 女孩"} · Lv.{pet.level}</div>
<span className="mt-2 inline-block rounded-full px-4 py-1 text-xs font-semibold text-white" style={{ background: stageClr }}>{STAGE[pet.stage] || "幼犬期"}</span>
<div className="mt-1.5 text-xs text-muted-foreground">领养 {daysSince(pet.adopted_at)} 天</div>
</div>
<div className="px-1">
<Bar label="心情" icon="😊" val={pet.happiness} color="#FF6B9D" />
<Bar label="饱腹" icon="🍖" val={pet.hunger} color="#FFB347" />
<Bar label="精力" icon="⚡" val={pet.energy} color="#4ECDC4" />
<Bar label="清洁" icon="🧹" val={pet.cleanliness} color="#A8E6CF" />
</div>
{pet.achievements?.length > 0 && (
<div>
<div className="mb-2 text-sm font-semibold">🏆 成就 ({pet.achievements.length})</div>
<div className="flex flex-wrap gap-1.5">{pet.achievements.map((a: any) => <Badge key={a.id} variant="secondary">{a.achievement_key}</Badge>)}</div>
</div>
)}
{pet.dailies?.length > 0 && (
<div>
<div className="mb-2 text-sm font-semibold">📋 日常记录 (近60天)</div>
<div className="max-h-52 overflow-y-auto text-xs">
{pet.dailies.map((dd: any) => (
<div key={dd.id} className="flex justify-between border-b py-1.5 last:border-b-0"><span>{dd.date}</span><span>{allDone(dd) ? "✅ 全部完成" : "🔄 部分完成"}</span></div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,75 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react"
import { X } from "lucide-react"
import { mediaUrl } from "@/lib/api"
import { cn } from "@/lib/utils"
const Ctx = createContext<(url: string) => void>(() => {})
// 全局图片灯箱(对应旧版 previewImage)
export function ImagePreviewProvider({ children }: { children: ReactNode }) {
const [url, setUrl] = useState<string | null>(null)
const open = useCallback((u: string) => u && setUrl(u), [])
return (
<Ctx.Provider value={open}>
{children}
{url && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/85 p-4" onClick={() => setUrl(null)}>
<button className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20" aria-label="关闭">
<X className="size-5" />
</button>
<img src={url} className="max-h-[92vh] max-w-[95vw] rounded-lg object-contain shadow-2xl" onClick={(e) => e.stopPropagation()} />
</div>
)}
</Ctx.Provider>
)
}
export function useImagePreview() {
return useContext(Ctx)
}
// 可点击放大的缩略图
export function Thumb({ path, className, title }: { path?: string | null; className?: string; title?: string }) {
const preview = useImagePreview()
const url = mediaUrl(path)
if (!url) return null
return (
<img
src={url}
title={title}
loading="lazy"
className={cn("size-14 cursor-zoom-in rounded-lg border object-cover transition hover:opacity-80", className)}
onClick={(e) => {
e.stopPropagation()
preview(url)
}}
/>
)
}
export function ThumbGallery({ paths, className }: { paths: string[]; className?: string }) {
if (!paths.length) return null
return (
<div className={cn("flex flex-wrap gap-1.5", className)}>
{paths.map((p, i) => (
<Thumb key={i} path={p} />
))}
</div>
)
}
// 头像:有图显示图,无图显示首字母渐变圆
export function Avatar({ path, name, size = 36 }: { path?: string | null; name?: string | null; size?: number }) {
const preview = useImagePreview()
const url = mediaUrl(path)
const style = { width: size, height: size, fontSize: size * 0.42 }
if (url)
return (
<img src={url} style={style} className="shrink-0 cursor-zoom-in rounded-full object-cover" onClick={(e) => { e.stopPropagation(); preview(url) }} />
)
return (
<div style={style} className="flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-violet-400 font-semibold text-white">
{(name || "?").trim().charAt(0).toUpperCase()}
</div>
)
}
@@ -0,0 +1,44 @@
import { useState } from "react"
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom"
import { useQueryClient, useIsFetching } from "@tanstack/react-query"
import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight } from "lucide-react"
import { toast } from "sonner"
import { api } from "@/lib/api"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
const NAV = [
{ to: "/", label: "数据概览", icon: BarChart3, group: "工作台", end: true },
{ to: "/users", label: "用户管理", icon: Users, group: "空间管理" },
{ to: "/couples", label: "情侣管理", icon: Heart, group: "空间管理" },
{ to: "/contents", label: "内容管理", icon: FileText, group: "空间管理" },
{ to: "/unbind", label: "解绑管理", icon: Unlink, group: "空间管理" },
{ to: "/pets", label: "宠物管理", icon: PawPrint, group: "空间管理" },
{ to: "/backgrounds", label: "背景管理", icon: Image, group: "系统" },
{ to: "/settings", label: "系统设置", icon: Settings, group: "系统" },
]
function NavItems({ onNavigate }: { onNavigate?: () => void }) {
return <nav className="admin-navigation">{["工作台", "空间管理", "系统"].map(group => <section key={group}><div className="nav-group-label">{group}</div>{NAV.filter(item => item.group === group).map(({to,label,icon: Icon,end}) => <NavLink key={to} to={to} end={end} onClick={onNavigate} className={({isActive}) => cn("admin-nav-link",isActive && "active")}><Icon size={18}/><span>{label}</span></NavLink>)}</section>)}</nav>
}
export default function Layout({ username }: { username: string }) {
const navigate = useNavigate(), location = useLocation(), qc = useQueryClient()
const [open, setOpen] = useState(false)
const fetching = useIsFetching()
const current = NAV.find(item => item.to === location.pathname)?.label || "管理后台"
async function logout() {
try { await api("adminLogout", null, "GET") }
catch { toast.error("退出失败,请重试"); return }
qc.clear(); toast.success("已退出"); navigate("/login", {replace:true})
}
const brand = <div className="admin-brand"><div className="brand-mark"><Heart size={22}/></div><div><strong>我们 past</strong><span>让陪伴有迹可循</span></div></div>
const account = <div className="sidebar-account"><div className="account-avatar">{username.slice(0,1).toUpperCase()}</div><div><strong>{username}</strong><span>管理员</span></div><Button variant="ghost" size="icon" aria-label="退出后台" onClick={logout}><LogOut size={17}/></Button></div>
return <div className="admin-shell">
<aside className="admin-sidebar">{brand}<NavItems/>{account}</aside>
<div className="admin-workspace">
<header className="admin-header"><div className="header-path"><Sheet open={open} onOpenChange={setOpen}><SheetTrigger asChild><Button variant="ghost" size="icon" className="md:hidden" aria-label="打开导航"><Menu size={20}/></Button></SheetTrigger><SheetContent side="left" className="w-72 p-5"><SheetTitle className="sr-only">管理导航</SheetTitle>{brand}<NavItems onNavigate={()=>setOpen(false)}/>{account}</SheetContent></Sheet><span className="hidden sm:inline">管理后台</span><ChevronRight size={14} className="hidden sm:block"/><strong>{current}</strong></div><div className="header-tools"><Button variant="outline" size="sm" onClick={()=>qc.invalidateQueries()} disabled={fetching>0}><RefreshCw className={cn("size-4",fetching>0&&"animate-spin")}/><span className="hidden sm:inline">刷新数据</span></Button><span className="header-account">{username}</span></div></header>
<main className="admin-main"><Outlet/></main>
<footer className="admin-footer">我们 past · 共同记录每一天</footer>
</div>
</div>
}
@@ -0,0 +1,43 @@
import { useState, type FormEvent } from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export const PAGE_SIZE = 10
// 后端分页响应的通用结构(admin_users / admin_couples / admin_unbind_requests / admin_pets / admin_contents)
export type Paged<T = any> = { items: T[]; total: number; page: number; page_size: number; total_pages: number }
// 分页条:上一页/下一页、页码窗口、跳转输入
export function Pagination({ page, totalPages, total, onChange }: { page: number; totalPages: number; total?: number; onChange: (p: number) => void }) {
const [jump, setJump] = useState("")
if (!total && totalPages <= 1) return null
const pages: number[] = []
for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i++) pages.push(i)
const go = (p: number) => onChange(Math.min(Math.max(1, totalPages), Math.max(1, p)))
const submitJump = (e: FormEvent) => {
e.preventDefault()
const n = parseInt(jump, 10)
if (!isNaN(n)) go(n)
setJump("")
}
return (
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 text-sm">
<span className="text-muted-foreground">
{total !== undefined && <>共 {total} 条 · </>}第 {page} / {Math.max(1, totalPages)} 页
</span>
<div className="flex flex-wrap items-center gap-1.5">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => go(page - 1)}><ChevronLeft className="size-4" />上一页</Button>
{pages[0] > 1 && (<><Button size="sm" variant="outline" onClick={() => go(1)}>1</Button>{pages[0] > 2 && <span className="px-1 text-muted-foreground">…</span>}</>)}
{pages.map((i) => <Button key={i} size="sm" variant={i === page ? "default" : "outline"} onClick={() => go(i)}>{i}</Button>)}
{pages.length > 0 && pages[pages.length - 1] < totalPages && (<>{pages[pages.length - 1] < totalPages - 1 && <span className="px-1 text-muted-foreground">…</span>}<Button size="sm" variant="outline" onClick={() => go(totalPages)}>{totalPages}</Button></>)}
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => go(page + 1)}>下一页<ChevronRight className="size-4" /></Button>
<form onSubmit={submitJump} className="ml-2 flex items-center gap-1.5">
<span className="text-muted-foreground">跳至</span>
<Input className="h-7 w-16 text-center" inputMode="numeric" value={jump} onChange={(e) => setJump(e.target.value.replace(/\D/g, ""))} placeholder="页码" />
<Button size="sm" variant="outline" type="submit">GO</Button>
</form>
</div>
</div>
)
}
@@ -0,0 +1,286 @@
import { useEffect, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { api, fmtTime, parseImages } from "@/lib/api"
import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview"
import { Empty, Loading, ErrorBox, RecordItem, genderText } from "@/components/common"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
// ---------- 用户详情(含编辑资料、打卡/日记/相册记录)----------
export function UserDetailDialog({ userId, onClose, onDelete }: { userId: number | null; onClose: () => void; onDelete: (u: { id: number; name: string; hasPartner: boolean }) => void }) {
const qc = useQueryClient()
const q = useQuery({
queryKey: ["admin_user_detail", userId],
queryFn: () => api("adminUserDetail", { user_id: userId }, "GET"),
enabled: !!userId,
})
const d = q.data
const [form, setForm] = useState({ nickname: "", gender: "", age: "", birthday: "" })
useEffect(() => {
if (d) setForm({ nickname: d.nickname || "", gender: d.gender || "", age: d.age ? String(d.age) : "", birthday: d.birthday || "" })
}, [d])
async function save() {
if (!userId) return
try {
await api("adminUpdateUser", {
user_id: userId,
nickname: form.nickname,
gender: form.gender,
age: form.age === "" ? undefined : Number(form.age),
birthday: form.birthday,
})
toast.success("资料已更新 💕")
qc.invalidateQueries({ queryKey: ["admin_user_detail", userId] })
qc.invalidateQueries({ queryKey: ["admin_users"] })
} catch (e) {
toast.error((e as Error).message)
}
}
const logs = d?.checkin_logs || []
const checkins = d?.checkins || []
const diaries = d?.diaries || []
const albums = d?.albums || []
return (
<Dialog open={!!userId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>👤 用户详情</DialogTitle>
<DialogDescription>查看用户信息和记录</DialogDescription>
</DialogHeader>
{q.isPending ? <Loading /> : q.isError ? <ErrorBox error={q.error} /> : (
<div className="space-y-4">
<div className="flex items-center gap-4 rounded-xl bg-gradient-to-r from-pink-50 to-violet-50 p-4 dark:from-pink-950/30 dark:to-violet-950/30">
<Avatar path={d.avatar} name={d.nickname || d.username} size={56} />
<div className="min-w-0 text-sm">
<div className="font-semibold">
{d.nickname || "-"}
<span className="ml-2 text-xs font-normal text-muted-foreground">
#{d.user_id || "-"} · {d.openid ? "微信用户" : "手机用户"}{d.openid ? ` · 微信ID: ${d.openid}` : ""}
</span>
</div>
<div className="text-muted-foreground">{genderText(d.gender)} · {d.age > 0 ? `${d.age}岁` : "-"} · 🎂{d.birthday || "-"}</div>
<div className="text-xs text-muted-foreground">
📍 登录地址:{d.last_login_location || d.location || "未记录"}{d.last_login_at ? ` · 🕒 ${fmtTime(d.last_login_at)}` : ""}
</div>
</div>
</div>
<div className="rounded-xl border p-4">
<div className="mb-3 text-sm font-semibold">✏️ 编辑资料</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label>💖 昵称</Label><Input value={form.nickname} onChange={(e) => setForm({ ...form, nickname: e.target.value })} /></div>
<div className="space-y-1">
<Label>⚧ 性别</Label>
<Select value={form.gender || "none"} onValueChange={(v) => setForm({ ...form, gender: v === "none" ? "" : v })}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">-</SelectItem>
<SelectItem value="male">男</SelectItem>
<SelectItem value="female">女</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label>🎂 年龄</Label><Input type="number" value={form.age} onChange={(e) => setForm({ ...form, age: e.target.value })} /></div>
<div className="space-y-1"><Label>🎂 生日</Label><Input type="date" value={form.birthday} onChange={(e) => setForm({ ...form, birthday: e.target.value })} /></div>
</div>
<div className="mt-3 flex gap-2">
<Button className="flex-1" onClick={save}>保存修改 💕</Button>
<Button variant="destructive" className="flex-1" onClick={() => onDelete({ id: userId!, name: d.nickname || d.username || "该用户", hasPartner: !!d.couple_id })}>
🗑️ 删除此用户
</Button>
</div>
</div>
<Tabs defaultValue="checkin">
<TabsList className="w-full">
<TabsTrigger value="checkin" className="flex-1">✅ 打卡</TabsTrigger>
<TabsTrigger value="diary" className="flex-1">📝 日记</TabsTrigger>
<TabsTrigger value="album" className="flex-1">📷 相册</TabsTrigger>
</TabsList>
<TabsContent value="checkin">
{!logs.length && !checkins.length ? <Empty icon="✅" text="暂无打卡记录" /> : (
<div className="space-y-3">
{logs.length > 0 && (
<div>
<div className="mb-1 text-xs font-semibold text-muted-foreground">📋 打卡记录 ({logs.length}条)</div>
{logs.map((l: any) => (
<div key={l.id} className="border-b py-2 text-sm last:border-b-0">
<span className="text-muted-foreground">{fmtTime(l.created_at)}</span>
{l.checkin_title && <Badge variant="secondary" className="ml-2">{l.checkin_icon || "✅"} {l.checkin_title}</Badge>}
{l.note && <span className="ml-2 text-xs text-muted-foreground">📝{l.note}</span>}
{l.photo && <div className="mt-1"><Thumb path={l.photo} /></div>}
</div>
))}
</div>
)}
{checkins.length > 0 && (
<div>
<div className="mb-1 text-xs font-semibold text-muted-foreground">📌 打卡项 ({checkins.length}项)</div>
{checkins.map((c: any) => <CheckinItem key={c.id} c={c} />)}
</div>
)}
</div>
)}
</TabsContent>
<TabsContent value="diary">
{!diaries.length ? <Empty icon="📝" text="暂无" /> : diaries.map((dd: any) => (
<RecordItem key={dd.id} icon="📝" right={<span className="text-xs text-muted-foreground">{dd.diary_date}</span>} footer={<ThumbGallery paths={parseImages(dd.images)} />}>
{dd.title || "一篇日记"}
</RecordItem>
))}
</TabsContent>
<TabsContent value="album">
{!albums.length ? <Empty icon="📷" text="暂无" /> : albums.map((a: any) => (
<RecordItem key={a.id} icon="📷">{a.title}</RecordItem>
))}
</TabsContent>
</Tabs>
</div>
)}
</DialogContent>
</Dialog>
)
}
function CheckinItem({ c }: { c: any }) {
const [open, setOpen] = useState(false)
const logs = c.logs || []
return (
<div className="border-b py-2 text-sm last:border-b-0">
<div className="flex cursor-pointer items-center gap-2" onClick={() => setOpen(!open)}>
<span>✅</span>
<span>{c.title || "打卡"}</span>
<span className="text-xs text-muted-foreground">({logs.length}条记录)</span>
<Badge variant={c.is_completed ? "default" : "outline"} className="ml-auto">{c.is_completed ? "已完成" : "待完成"}</Badge>
<span className="text-xs text-muted-foreground">{open ? "▲" : "▼"}</span>
</div>
{open && (
<div className="mt-1 border-t border-dashed pl-6 pt-1">
{!logs.length ? <div className="py-1 text-xs text-muted-foreground">暂无打卡记录</div> : logs.map((l: any) => (
<div key={l.id} className="py-1 text-xs">
<span className="text-muted-foreground">{fmtTime(l.created_at)}</span>
{l.note && <span className="ml-2">📝{l.note}</span>}
{l.photo && <div className="mt-1"><Thumb path={l.photo} className="size-12" /></div>}
</div>
))}
</div>
)}
</div>
)
}
// ---------- 重置密码 ----------
export function ResetPasswordDialog({ user, onClose }: { user: { id: number; name: string } | null; onClose: () => void }) {
const [p1, setP1] = useState("")
const [p2, setP2] = useState("")
useEffect(() => { setP1(""); setP2("") }, [user])
async function submit() {
if (!user) return
if (p1.length < 6) return toast.error("新密码至少6位")
if (p1 !== p2) return toast.error("两次输入的密码不一致")
try {
await api("adminResetUserPassword", { user_id: user.id, new_password: p1 })
toast.success("密码重置成功 🔑")
onClose()
} catch (e) {
toast.error((e as Error).message)
}
}
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>🔑 重置密码</DialogTitle>
<DialogDescription>为 {user?.name} 设置新密码</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>新密码</Label><Input type="password" value={p1} onChange={(e) => setP1(e.target.value)} placeholder="6位以上" /></div>
<div className="space-y-1"><Label>确认密码</Label><Input type="password" value={p2} onChange={(e) => setP2(e.target.value)} /></div>
<Button className="w-full" onClick={submit}>确认重置</Button>
</div>
</DialogContent>
</Dialog>
)
}
// ---------- 删除用户确认 ----------
export function DeleteUserDialog({ user, onClose, onDone }: { user: { id: number; name: string; hasPartner: boolean } | null; onClose: () => void; onDone?: () => void }) {
const qc = useQueryClient()
async function confirm() {
if (!user) return
try {
const r = await api("adminDeleteUser", { user_id: user.id })
toast.success(r.msg || "已删除")
qc.invalidateQueries({ queryKey: ["admin_users"] })
qc.invalidateQueries({ queryKey: ["admin_couples"] })
qc.invalidateQueries({ queryKey: ["admin_stats"] })
onClose()
onDone?.()
} catch (e) {
toast.error((e as Error).message)
}
}
return (
<AlertDialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>⚠️ 确认删除用户「{user?.name}」</AlertDialogTitle>
<AlertDialogDescription>
{user?.hasPartner
? "该用户已绑定情侣:将同时删除其伴侣账号以及全部情侣数据(日记、相册、照片、打卡等)。此操作不可恢复!"
: "将删除该用户账号及其邀请记录。此操作不可恢复!"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={confirm}>确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ---------- 删除内容确认(情侣详情 / 内容管理共用)----------
export type DeleteContentTarget = { type: string; id: number; coupleId: number; label: string }
export function DeleteContentDialog({ target, onClose, onDone }: { target: DeleteContentTarget | null; onClose: () => void; onDone?: () => void }) {
async function confirm() {
if (!target) return
try {
await api("adminDeleteContent", { type: target.type, id: target.id, couple_id: target.coupleId })
toast.success("已删除 🗑️")
onClose()
onDone?.()
} catch (e) {
toast.error((e as Error).message)
}
}
return (
<AlertDialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>⚠️ 删除内容</AlertDialogTitle>
<AlertDialogDescription>确定要删除「{target?.label}」吗?删除后无法恢复!</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={confirm}>确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,73 @@
import { Inbox, AlertCircle } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
// 页面标题(右侧可放计数)
export function PageTitle({ children, count }: { children: ReactNode; count?: ReactNode }) {
return (
<h2 className="admin-page-title">
{children}
{count !== undefined && <span className="page-count">{count}</span>}
</h2>
)
}
export function Empty({ icon = "📋", text = "暂无数据", className }: { icon?: string; text?: string; className?: string }) {
return (
<div className={cn("admin-empty", className)}>
<div className="empty-mark">{icon === "❌" ? <AlertCircle size={26}/> : <Inbox size={26}/>}</div>
<p className="text-sm">{text}</p>
</div>
)
}
export function Loading({ rows = 4 }: { rows?: number }) {
return (
<div className="space-y-2 py-2">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
)
}
export function ErrorBox({ error }: { error: unknown }) {
return <Empty icon="❌" text={(error as Error)?.message || "加载失败"} />
}
// 统计卡片
export function StatCard({ label, value, tone = "pink" }: { label: string; value: ReactNode; tone?: "pink" | "violet" | "green" | "blue" | "amber" | "neutral" }) {
const tones: Record<string, string> = {
pink: "from-pink-50 to-pink-100 text-pink-600 dark:from-pink-950/40 dark:to-pink-900/30 dark:text-pink-300",
violet: "from-violet-50 to-violet-100 text-violet-600 dark:from-violet-950/40 dark:to-violet-900/30 dark:text-violet-300",
green: "from-emerald-50 to-emerald-100 text-emerald-600 dark:from-emerald-950/40 dark:to-emerald-900/30 dark:text-emerald-300",
blue: "from-sky-50 to-sky-100 text-sky-600 dark:from-sky-950/40 dark:to-sky-900/30 dark:text-sky-300",
amber: "from-amber-50 to-amber-100 text-amber-600 dark:from-amber-950/40 dark:to-amber-900/30 dark:text-amber-300",
neutral: "from-muted to-muted text-foreground",
}
return (
<div className={cn("rounded-xl bg-gradient-to-br p-5 text-left", tones[tone])}>
<div className="text-3xl font-semibold tracking-tight">{value}</div>
<div className="mt-1 text-xs text-muted-foreground">{label}</div>
</div>
)
}
// 记录列表项(对应旧版 .record-item)
export function RecordItem({ icon, children, right, footer, onClick }: { icon?: ReactNode; children: ReactNode; right?: ReactNode; footer?: ReactNode; onClick?: () => void }) {
return (
<div className={cn("border-b py-2.5 last:border-b-0", onClick && "cursor-pointer hover:bg-muted/50")} onClick={onClick}>
<div className="flex items-center gap-3">
{icon && <span className="w-6 text-center text-lg">{icon}</span>}
<div className="min-w-0 flex-1 text-sm">{children}</div>
{right}
</div>
{footer && <div className="pl-9 pt-1.5">{footer}</div>}
</div>
)
}
export function genderText(g?: string | null) {
return g === "male" ? "♂ 男" : g === "female" ? "♀ 女" : "-"
}
@@ -0,0 +1,197 @@
import * as React from "react"
import { cn } from "cn"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,166 @@
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "cn"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Select as SelectPrimitive } from "radix-ui"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "cn"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as SheetPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,13 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,113 @@
import * as React from "react"
import { cn } from "cn"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Tabs as TabsPrimitive } from "radix-ui"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "cn"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+152
View File
@@ -0,0 +1,152 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button:not(:disabled), [role="button"]:not(:disabled) {
cursor: pointer;
}
html {
@apply font-sans;
}
}
/* 我们past:粉色主色,沿用旧后台风格 */
:root {
--primary: oklch(0.68 0.19 5);
--primary-foreground: oklch(0.99 0 0);
--ring: oklch(0.68 0.19 5 / 40%);
}
.dark {
--primary: oklch(0.72 0.17 5);
--primary-foreground: oklch(0.15 0 0);
}
body { background: var(--background); }
/* Warm rose management workspace. Component tokens keep every page consistent. */
:root { --background:#FAF8FA; --foreground:#302A32; --card:#FFF; --card-foreground:#302A32; --primary:#BD5274; --primary-foreground:#FFF; --secondary:#F4EBF0; --secondary-foreground:#795569; --muted:#F5F0F3; --muted-foreground:#817482; --accent:#F7E9F0; --accent-foreground:#A44365; --border:#EDE4EA; --input:#E5DAE2; --ring:#BD527460; --radius:.8rem; --sidebar:#FFF; }
body{font-family:'Geist Variable',-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif}
.admin-shell{display:flex;min-height:100vh}.admin-sidebar{width:248px;flex-shrink:0;height:100vh;position:sticky;top:0;display:flex;flex-direction:column;padding:24px 16px 16px;border-right:1px solid var(--border);background:#FFF}.admin-brand{display:flex;align-items:center;gap:12px;padding:6px 12px 26px}.brand-mark{width:44px;height:44px;border-radius:15px;display:grid;place-items:center;background:linear-gradient(135deg,#F6DAE5,#FCEBF2);color:var(--primary)}.admin-brand strong{display:block;font-size:19px;letter-spacing:-.5px}.admin-brand span{display:block;font-size:11px;color:var(--muted-foreground);margin-top:4px}.admin-navigation{display:flex;flex-direction:column;gap:18px;flex:1;overflow-y:auto}.nav-group-label{font-size:11px;color:#A092A0;padding:0 14px;margin:6px 0 8px;letter-spacing:1px}.admin-nav-link{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;font-size:14px;font-weight:500;color:#817482;margin:3px 0;transition:background .15s,color .15s}.admin-nav-link:hover{background:#FAF3F7;color:#A44365}.admin-nav-link.active{background:#F8EAF1;color:#A44365}.admin-nav-link.active:after{content:'';width:5px;height:5px;border-radius:50%;background:#BD5274;margin-left:auto}.sidebar-account{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:18px 8px 0;margin-top:20px}.account-avatar{width:36px;height:36px;border-radius:12px;background:#F4EBF0;display:grid;place-items:center;color:#A44365;font-weight:600}.sidebar-account>div:nth-child(2){flex:1;min-width:0}.sidebar-account strong{display:block;font-size:12px;overflow:hidden;text-overflow:ellipsis}.sidebar-account span{display:block;font-size:11px;color:#A092A0;margin-top:3px}.admin-workspace{flex:1;min-width:0;display:flex;flex-direction:column}.admin-header{height:72px;padding:0 32px;background:#FFFFFFE8;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:20;backdrop-filter:blur(16px)}.header-path,.header-tools{display:flex;align-items:center;gap:12px;font-size:13px;color:#938592}.header-path strong{font-size:14px;color:var(--foreground);font-weight:500}.header-account{font-size:12px;color:#796B78;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.admin-main{padding:32px;flex:1;max-width:1680px;width:100%;margin:auto}.admin-footer{text-align:center;padding:16px 24px 24px;font-size:11px;color:#B0A0AD}.admin-page-title{display:flex;align-items:baseline;gap:12px;font-size:24px;font-weight:600;letter-spacing:-.7px;margin-bottom:24px}.page-count{background:#F1E8EE;color:#9B7187;font-size:12px;border-radius:8px;padding:3px 9px;letter-spacing:0}.dashboard-intro{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px}.dashboard-intro .admin-page-title{margin-bottom:6px}.dashboard-intro p{font-size:13px;color:#938592}.metric-card{box-shadow:0 4px 16px #4A244505;transition:transform .15s,box-shadow .15s}.metric-card:hover{transform:translateY(-2px);box-shadow:0 8px 22px #4A24450D}.metric-icon{width:44px;height:44px;display:grid;place-items:center;border-radius:14px;background:#F7F0F4;color:#B07590;flex-shrink:0}.metric-featured{background:linear-gradient(135deg,#FFF,#FFF0F6)}.metric-featured .metric-icon{background:#F8E2ED;color:#BD5274}.admin-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:56px 20px;color:#938592}.empty-mark{background:#F6EDF2;color:#BB8BA2;border-radius:18px;width:64px;height:64px;display:grid;place-items:center}.admin-empty p{font-size:14px}
[data-slot=card]{box-shadow:0 4px 18px #4A244505}[data-slot=card-title]{font-weight:600}[data-slot=table-head]{height:48px;background:#FAF7F9;font-size:12px;font-weight:500;color:#938592}[data-slot=table-cell]{padding-top:15px;padding-bottom:15px}[data-slot=table-row]:hover{background:#FCF8FA}[data-slot=dialog-content]{border-radius:20px;box-shadow:0 24px 80px #3A233A22}[data-slot=input]{min-height:38px;background:#FFF}[data-slot=button]{font-weight:500}[data-slot=badge]{font-weight:500}
.admin-login{min-height:100vh;display:grid;grid-template-columns:1.1fr 1fr;background:#FCF9FB}.login-story{background:linear-gradient(145deg,#FBE8F1,#F5EDF7);padding:40px 48px;display:flex;flex-direction:column;position:relative;overflow:hidden}.login-story-main{margin:auto;width:100%;max-width:500px}.login-art{width:176px;height:176px;border-radius:54px;display:grid;place-items:center;background:#FFFFFF70;color:#BD5274;margin-bottom:38px;transform:rotate(-8deg)}.login-art svg{transform:rotate(8deg)}.login-story h1{font-size:40px;line-height:1.5;font-weight:600;letter-spacing:-1.5px;color:#594054}.login-story p{font-size:15px;color:#A27A93;margin-top:18px}.login-features{display:flex;flex-wrap:wrap;gap:20px;margin-top:40px;font-size:12px;color:#956C85}.login-features span{display:flex;align-items:center;gap:8px}.login-form-area{display:flex;align-items:center;justify-content:center;flex-direction:column;padding:48px 24px}.login-card{width:100%;max-width:400px;padding:32px 12px;box-shadow:0 16px 60px #61355308}.login-eyebrow{font-size:12px;color:#BD5274;margin-bottom:18px;letter-spacing:1px}.login-footnote{font-size:11px;color:#AF9DAB;margin-top:24px}
@media(max-width:767px){.admin-sidebar{display:none}.admin-header{padding:0 16px;height:60px}.admin-main{padding:22px 16px}.admin-page-title{font-size:22px}.header-account{display:none}.dashboard-intro{align-items:flex-start}.dashboard-intro>button{font-size:11px}.admin-login{display:block}.login-story{display:none}.login-form-area{min-height:100vh}.login-card{max-width:380px}}
+96
View File
@@ -0,0 +1,96 @@
// 统一 /api/接口名;管理后台继续使用同源 Cookie 会话。
export const API_BASE = "/api"
export const MEDIA_BASE = "/backend/"
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
export function camelName(key: string): string { return key.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase()) }
function snakeName(key: string): string { return key.replace(/[A-Z]/g, c => "_" + c.toLowerCase()) }
function mapKeys(value: any, name: (key: string) => string): any {
if (Array.isArray(value)) return value.map(v => mapKeys(v, name))
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [name(k), mapKeys(v, name)]))
return value
}
const toWire = (value: unknown) => mapKeys(value, camelName)
const fromWire = (value: unknown) => mapKeys(value, snakeName)
type Params = Record<string, string | number | boolean | null | undefined>
export async function api<T = any>(action: string, data?: Params | null, method: "GET" | "POST" = "POST"): Promise<T> {
let url = `${API_BASE}/${encodeURIComponent(camelName(action))}`
const init: RequestInit = { method, credentials: "same-origin", cache: "no-store", headers: {} }
if (data && method === "GET") {
const q = new URLSearchParams()
for (const [k, v] of Object.entries(data)) if (v !== null && v !== undefined && v !== "") q.set(camelName(k), String(v))
const s = q.toString()
if (s) url += "?" + s
} else if (data && method === "POST") {
init.headers = { "Content-Type": "application/json" }
init.body = JSON.stringify(toWire(data))
}
const res = await fetch(url, init)
let json: any = null
try {
json = await res.json()
} catch {
throw new ApiError("响应解析失败", res.status)
}
if (!res.ok || (json && json.error)) throw new ApiError(json?.error || "操作失败", res.status)
return fromWire(json) as T
}
// multipart 上传(如全局背景)
export async function apiUpload<T = any>(action: string, form: FormData): Promise<T> {
const wireForm = new FormData()
form.forEach((value, key) => wireForm.append(camelName(key), value))
const res = await fetch(`${API_BASE}/${encodeURIComponent(camelName(action))}`, { method: "POST", credentials: "same-origin", body: wireForm })
const json = await res.json()
if (!res.ok || json.error) throw new ApiError(json.error || "上传失败", res.status)
return fromWire(json) as T
}
// v 参数:使后台的图片缓存键与旧的(曾被缓存的 404)不同;小程序端不受影响
const MEDIA_VER = "2"
export function mediaUrl(path?: string | null): string {
if (!path) return ""
if (path.startsWith("http://") || path.startsWith("https://")) return path
return MEDIA_BASE + path.replace(/^\//, "") + "?v=" + MEDIA_VER
}
// "2026-09-17 10:31:52" → "2026-09-17 10:31"
export function fmtTime(s?: string | null): string {
if (!s) return "-"
return String(s).substring(0, 16).replace("T", " ")
}
export function parseImages(v: unknown): string[] {
if (!v) return []
if (Array.isArray(v)) return v as string[]
if (typeof v === "string") {
try {
const j = JSON.parse(v)
return Array.isArray(j) ? j : []
} catch {
return []
}
}
return []
}
export function daysSince(date?: string | null): number {
if (!date) return 0
const d = new Date(date)
if (isNaN(d.getTime())) return 0
return Math.floor((Date.now() - d.getTime()) / 86400000)
}
export const BREED: Record<string, string> = { golden: "金毛", husky: "哈士奇", corgi: "柯基", shiba: "柴犬" }
export const BREED_EMOJI: Record<string, string> = { golden: "🐕", husky: "🐺", corgi: "🐶", shiba: "🦊" }
export const STAGE: Record<number, string> = { 1: "幼犬期", 2: "少年期", 3: "青年期", 4: "成年期", 5: "终极期" }
export const STAGE_COLOR = ["#FF9A9E", "#A8E6CF", "#FFD3B6", "#D4A5FF", "#FFD700"]
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn"
+25
View File
@@ -0,0 +1,25 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Toaster } from "sonner"
import { ImagePreviewProvider } from "@/components/ImagePreview"
import App from "./App"
import "./index.css"
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 15_000 } },
})
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/backend/admin">
<ImagePreviewProvider>
<App />
</ImagePreviewProvider>
</BrowserRouter>
<Toaster position="top-center" richColors />
</QueryClientProvider>
</StrictMode>,
)
@@ -0,0 +1,98 @@
import { useRef, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { api, apiUpload, mediaUrl } from "@/lib/api"
import { useImagePreview } from "@/components/ImagePreview"
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
export default function BackgroundsPage() {
const qc = useQueryClient()
const preview = useImagePreview()
const global = useQuery({ queryKey: ["admin_get_global_bg"], queryFn: () => api("adminGetGlobalBg", null, "GET") })
const list = useQuery({ queryKey: ["admin_couple_bg_list"], queryFn: () => api<any[]>("adminCoupleBgList", null, "GET") })
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [clearId, setClearId] = useState<number | null>(null)
async function upload(f?: File) {
if (!f) return
const fd = new FormData()
fd.append("bg", f)
setUploading(true)
try {
await apiUpload("adminSetGlobalBg", fd)
toast.success("全局背景设置成功 🌸")
qc.invalidateQueries({ queryKey: ["admin_get_global_bg"] })
} catch (e) {
toast.error((e as Error).message)
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ""
}
}
async function clearGlobal() {
try {
await api("adminClearGlobalBg", null, "POST")
toast.success("全局背景已清除")
qc.invalidateQueries({ queryKey: ["admin_get_global_bg"] })
} catch (e) { toast.error((e as Error).message) }
}
async function clearCouple() {
if (!clearId) return
try {
await api("adminClearCoupleBg", { couple_id: clearId })
toast.success("背景已清除")
qc.invalidateQueries({ queryKey: ["admin_couple_bg_list"] })
} catch (e) { toast.error((e as Error).message) } finally { setClearId(null) }
}
const bg = global.data?.bg_image
return (
<div>
<PageTitle>背景管理</PageTitle>
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader><CardTitle className="text-base">全局默认背景</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="flex h-40 items-center justify-center overflow-hidden rounded-xl bg-muted">
{global.isPending ? <span className="text-muted-foreground">加载中…</span> : bg ? (
<img src={mediaUrl(bg)} className="size-full cursor-zoom-in object-cover" onClick={() => preview(mediaUrl(bg))} />
) : <span className="text-sm text-muted-foreground">暂无设置(使用默认背景)</span>}
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" disabled={uploading} onClick={() => fileRef.current?.click()}>{uploading ? "上传中…" : "📷 选择图片"}</Button>
<Button variant="outline" className="flex-1 text-destructive" onClick={clearGlobal}>🗑️ 清除</Button>
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => upload(e.target.files?.[0])} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">情侣背景列表 <span className="text-sm font-normal text-muted-foreground">{list.data ? `共 ${list.data.length} 个` : ""}</span></CardTitle></CardHeader>
<CardContent className="max-h-[420px] overflow-y-auto">
{list.isPending ? <Loading /> : list.isError ? <ErrorBox error={list.error} /> : !list.data.length ? <Empty icon="🖼️" text="暂无情侣背景" /> : list.data.map((it) => (
<div key={it.couple_id} className="flex items-center gap-3 border-b py-2.5 last:border-b-0">
{it.bg_image ? (
<img src={mediaUrl(it.bg_image)} className="size-14 shrink-0 cursor-zoom-in rounded-lg object-cover" onClick={() => preview(mediaUrl(it.bg_image))} />
) : <div className="flex size-14 shrink-0 items-center justify-center rounded-lg bg-muted text-2xl">🌐</div>}
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{it.couple_name || `情侣#${it.couple_id}`}</div>
<div className="text-xs text-muted-foreground">空间ID: {it.couple_space_id || "-"}</div>
</div>
{it.bg_image && <Button size="sm" variant="destructive" onClick={() => setClearId(it.couple_id)}>🗑️</Button>}
</div>
))}
</CardContent>
</Card>
</div>
<AlertDialog open={!!clearId} onOpenChange={(o) => !o && setClearId(null)}>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>清除情侣背景</AlertDialogTitle><AlertDialogDescription>确定要清除此情侣的背景吗?</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction variant="destructive" onClick={clearCouple}>清除</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useSearchParams } from "react-router-dom"
import { RefreshCw } from "lucide-react"
import { api, fmtTime } from "@/lib/api"
import { Avatar, Thumb, ThumbGallery } from "@/components/ImagePreview"
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
import { DeleteContentDialog, type DeleteContentTarget } from "@/components/UserDialogs"
import { Pagination, PAGE_SIZE } from "@/components/Pagination"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const TYPES = [
["diary", "📝 日记"], ["timeline", "📅 记忆日"], ["album", "📷 相册"], ["photo", "🖼️ 照片"], ["song", "🎵 歌曲"], ["checkin", "✅ 打卡"],
] as const
const ICON: Record<string, string> = { diary: "📝", timeline: "📅", album: "📷", photo: "🖼️", song: "🎵", checkin: "✅" }
export default function ContentsPage() {
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const urlType = searchParams.get("type")
const [type, setType] = useState(() => (urlType && TYPES.some(([v]) => v === urlType) ? urlType : "diary"))
useEffect(() => {
if (urlType && TYPES.some(([v]) => v === urlType)) setType(urlType)
}, [urlType])
const [couple, setCouple] = useState("0")
const [user, setUser] = useState("0")
const [start, setStart] = useState("")
const [end, setEnd] = useState("")
const [page, setPage] = useState(1)
const [del, setDel] = useState<DeleteContentTarget | null>(null)
const params = { type, couple_id: couple !== "0" ? couple : undefined, user_id: user !== "0" ? user : undefined, start_date: start, end_date: end, page, page_size: PAGE_SIZE }
const q = useQuery({ queryKey: ["admin_contents", params], queryFn: () => api("adminContents", params, "GET") })
const d = q.data
const reset = <T,>(setter: (v: T) => void) => (v: T) => { setter(v); setPage(1) }
function item(it: any) {
let title = "", detail: React.ReactNode = null, images: React.ReactNode = null
switch (type) {
case "diary":
title = it.title || "无标题"
detail = it.content ? <div className="text-xs text-muted-foreground">{String(it.content).substring(0, 100)}</div> : null
images = <ThumbGallery paths={Array.isArray(it.images) ? it.images : []} />
break
case "timeline":
title = it.title || "无标题"
detail = it.location ? <span className="text-xs text-muted-foreground">📍 {it.location}</span> : null
images = <div className="flex flex-wrap gap-1.5"><Thumb path={it.bg_image} /><ThumbGallery paths={Array.isArray(it.images) ? it.images : []} /></div>
break
case "album":
title = it.title || "无标题"
detail = <span className="text-xs text-muted-foreground">{it.description || ""} · 🖼️{it.photo_count || 0}张</span>
images = <Thumb path={it.cover} className="size-10" />
break
case "photo":
title = it.description || "照片"
images = <Thumb path={it.path} />
break
case "song":
title = it.song_name || "未知歌曲"
detail = <span className="text-xs text-muted-foreground">🎤 {it.artist || ""}{it.note ? ` · 📝 ${it.note}` : ""}</span>
break
case "checkin":
title = it.title || "打卡"
detail = <span className="text-xs text-muted-foreground">{it.type === "both" || it.type === "dual" ? "双人" : "单人"} · 我{it.completed_by_me ? "✅" : "⬜"} TA{it.completed_by_partner ? "✅" : "⬜"}</span>
break
}
return (
<div key={it.id} className="border-b py-3 last:border-b-0">
<div className="flex items-center gap-2.5">
<span className="text-lg">{ICON[type]}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="max-w-64 truncate text-sm font-medium">{title}</span>
<span className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(it.created_at)}</span>
</div>
{detail}
</div>
<Button size="xs" variant="destructive" onClick={() => setDel({ type, id: it.id, coupleId: it.couple_id || 0, label: `${ICON[type]} ${String(title).substring(0, 30)}` })}>🗑️ 删除</Button>
</div>
<div className="mt-1.5 flex items-center gap-3 pl-8 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><Avatar path={it.author_avatar} name={it.author_name} size={22} /><span className="text-foreground">{it.author_name || "匿名"}</span>#{it.author_display_id || "-"}</span>
<span>情侣ID: {it.couple_id || "-"}</span>
</div>
{images && <div className="mt-1 pl-8">{images}</div>}
</div>
)
}
const totalPages = d?.total_pages || 0
return (
<div>
<PageTitle count={d ? `共 ${d.total} 条` : undefined}>内容管理</PageTitle>
<Card>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1"><Label>内容类型</Label>
<Select value={type} onValueChange={(v) => { setType(v); setPage(1); setSearchParams(v === "diary" ? {} : { type: v }) }}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>{TYPES.map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label>筛选情侣</Label>
<Select value={couple} onValueChange={reset(setCouple)}><SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="0">全部情侣</SelectItem>
{(d?.couples || []).map((c: any) => <SelectItem key={c.id} value={String(c.id)}>{c.user1_name || "?"} & {c.user2_name || "?"} ({c.couple_space_id || `CP${String(c.id).padStart(6, "0")}`})</SelectItem>)}
</SelectContent></Select></div>
<div className="space-y-1"><Label>筛选用户</Label>
<Select value={user} onValueChange={reset(setUser)}><SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="0">全部用户</SelectItem>
{(d?.users || []).map((u: any) => <SelectItem key={u.id} value={String(u.id)}>{u.nickname || u.username || `用户#${u.id}`} (#{u.user_id || u.id})</SelectItem>)}
</SelectContent></Select></div>
<div className="space-y-1"><Label>开始日期</Label><Input type="date" value={start} onChange={(e) => reset(setStart)(e.target.value)} /></div>
<div className="space-y-1"><Label>结束日期</Label><Input type="date" value={end} onChange={(e) => reset(setEnd)(e.target.value)} /></div>
<Button variant="outline" onClick={() => q.refetch()}><RefreshCw className="size-4" />刷新</Button>
</div>
{q.isPending ? <Loading rows={6} /> : q.isError ? <ErrorBox error={q.error} /> : !d.items?.length ? <Empty icon="📋" text="暂无内容" /> : (
<div>{d.items.map(item)}</div>
)}
{d && d.total > 0 && <Pagination page={page} totalPages={totalPages} total={d.total} onChange={setPage} />}
</CardContent>
</Card>
<DeleteContentDialog target={del} onClose={() => setDel(null)} onDone={() => qc.invalidateQueries({ queryKey: ["admin_contents"] })} />
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
import { CoupleDetailDialog } from "@/components/CoupleDetailDialog"
import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination"
import { Card, CardContent } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function CouplesPage() {
const [page, setPage] = useState(1)
const q = useQuery({ queryKey: ["admin_couples", page], queryFn: () => api<Paged>("adminCouples", { page, page_size: PAGE_SIZE }, "GET") })
const [cid, setCid] = useState<number | null>(null)
const list = q.data?.items || []
return (
<div>
<PageTitle count={q.data ? `${q.data.total} 对` : undefined}>情侣管理</PageTitle>
<Card>
<CardContent>
{q.isPending ? <Loading rows={6} /> : q.isError ? <ErrorBox error={q.error} /> : !list.length ? <Empty icon="💑" text="暂无情侣数据" /> : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>用户1</TableHead><TableHead /><TableHead>用户2</TableHead><TableHead>纪念日</TableHead>
<TableHead>空间ID</TableHead><TableHead>状态</TableHead><TableHead>记忆日</TableHead><TableHead>日记·歌曲·打卡</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((c) => (
<TableRow key={c.id} className="cursor-pointer" onClick={() => setCid(c.id)}>
<TableCell className="font-medium">{c.user1_nick || c.user1_name || "?"}</TableCell>
<TableCell>💕</TableCell>
<TableCell className="font-medium">{c.user2_nick || c.user2_name || "?"}</TableCell>
<TableCell>{c.love_start_date || "-"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{c.couple_space_id || "-"}</TableCell>
<TableCell className="text-xs">{c.status === "unbound" ? "🔓 已解绑" : "💕 绑定中"}</TableCell>
<TableCell>{c.timeline_count || 0}</TableCell>
<TableCell>{c.diary_count || 0} · {c.songs_count || 0} · {c.checkin_count || 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage} />
</div>
)}
</CardContent>
</Card>
<CoupleDetailDialog coupleId={cid} onClose={() => setCid(null)} />
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { Users, Heart, CalendarDays, BookOpen, Images, Image, Music, CheckSquare, PawPrint, Award, ArrowUpRight } from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { cn } from "@/lib/utils"
import { PageTitle, StatCard, Loading, ErrorBox } from "@/components/common"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
const STAT_ITEMS: [string, string, string][] = [
["total_users", "用户", "👥"], ["total_couples", "情侣", "💑"], ["total_timelines", "记忆日", "📅"],
["total_diaries", "日记", "📝"], ["total_albums", "相册", "📷"], ["total_photos", "照片", "🖼️"],
["total_songs", "歌曲", "🎵"], ["total_checkins", "打卡", "✅"], ["total_pets", "宠物", "🐾"], ["total_pet_achievements", "宠物成就", "🏆"],
]
const METRIC_ICONS = [Users, Heart, CalendarDays, BookOpen, Images, Image, Music, CheckSquare, PawPrint, Award]
const CONTENT_LABEL: Record<string, string> = {
timeline: "📅 记忆日", diaries: "📝 日记", albums: "📷 相册", photos: "🖼️ 照片", songs: "🎵 歌曲", checkins: "✅ 打卡项", checkin_logs: "📋 打卡记录",
}
const STAT_LINK: Record<string, string> = {
total_users: "/users", total_couples: "/couples", total_timelines: "/contents?type=timeline",
total_diaries: "/contents?type=diary", total_albums: "/contents?type=album", total_photos: "/contents?type=photo",
total_songs: "/contents?type=song", total_checkins: "/contents?type=checkin", total_pets: "/pets", total_pet_achievements: "/pets",
}
const CONTENT_LINK: Record<string, string> = {
timeline: "timeline", diaries: "diary", albums: "album", photos: "photo", songs: "song", checkins: "checkin", checkin_logs: "checkin",
}
export default function Dashboard() {
const navigate = useNavigate()
const stats = useQuery({ queryKey: ["admin_stats"], queryFn: () => api("adminStats", null, "GET") })
const today = useQuery({ queryKey: ["admin_stats_detail", "today"], queryFn: () => api("adminStatsDetail", null, "GET") })
const [date, setDate] = useState("")
const [queryDate, setQueryDate] = useState("")
const byDate = useQuery({
queryKey: ["admin_stats_detail", queryDate],
queryFn: () => api("adminStatsDetail", { date: queryDate }, "GET"),
enabled: !!queryDate,
})
return (
<div className="space-y-5">
<div className="dashboard-intro"><div><PageTitle>空间动态</PageTitle><p>用户、关系与生活记录,一览即知。</p></div><Button variant="outline" onClick={() => navigate("/unbind")}>查看解绑请求 <ArrowUpRight size={16}/></Button></div>
{stats.isPending ? <Loading rows={2} /> : stats.isError ? <ErrorBox error={stats.error} /> : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
{STAT_ITEMS.map(([k, label], index) => {
const MetricIcon = METRIC_ICONS[index]
const link = STAT_LINK[k]
return (
<Card
key={k}
className={cn("metric-card py-4 transition-colors", index < 2 && "metric-featured", link && "cursor-pointer")}
onClick={link ? () => navigate(link) : undefined}
>
<CardContent className="flex items-center gap-3">
<span className="metric-icon"><MetricIcon size={22}/></span>
<div>
<div className="text-2xl font-semibold tracking-tight">{stats.data[k] ?? 0}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
<Card>
<CardHeader><CardTitle className="text-base">用户注册统计</CardTitle></CardHeader>
<CardContent>
{today.isPending ? <Loading rows={1} /> : today.isError ? <ErrorBox error={today.error} /> : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard label="今日新注册" value={today.data.today_registers} tone="pink" />
<StatCard label="本月新注册" value={today.data.month_registers} tone="violet" />
<StatCard label="总用户数" value={today.data.total_users} tone="green" />
<StatCard label="总情侣数" value={today.data.total_couples} tone="blue" />
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">按日期查询</CardTitle></CardHeader>
<CardContent className="space-y-4">
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => { e.preventDefault(); if (date) setQueryDate(date) }}
>
<div className="min-w-48 space-y-1.5">
<Label htmlFor="qd">选择日期</Label>
<Input id="qd" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<Button type="submit" disabled={!date}>查询</Button>
</form>
{byDate.isFetching && <Loading rows={1} />}
{byDate.isError && <ErrorBox error={byDate.error} />}
{byDate.data && !byDate.isFetching && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<StatCard label="注册用户" value={byDate.data.date_registers} tone="pink" />
<StatCard label="绑定情侣" value={byDate.data.date_couples} tone="violet" />
<StatCard label="发表内容" value={byDate.data.date_contents_total} tone="green" />
</div>
<div className="rounded-xl bg-muted/60 p-3">
<div className="mb-2 text-xs text-muted-foreground">📋 {byDate.data.date} 内容分类详情</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 md:grid-cols-7">
{Object.entries(byDate.data.date_contents || {}).map(([k, v]) => {
const t = CONTENT_LINK[k]
return (
<div
key={k}
className={cn("rounded-lg bg-background p-2 text-center", t && "cursor-pointer hover:bg-muted")}
onClick={t ? () => navigate(`/contents?type=${t}`) : undefined}
>
<div className="font-semibold">{v as number}</div>
<div className="text-xs text-muted-foreground">{CONTENT_LABEL[k] || k}</div>
</div>
)
})}
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { Heart, Users, NotebookPen, Settings } from "lucide-react"
import { useState, type FormEvent } from "react"
import { useNavigate } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { api } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function Login() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [busy, setBusy] = useState(false)
const navigate = useNavigate()
const qc = useQueryClient()
async function submit(e: FormEvent) {
e.preventDefault()
if (!username.trim() || !password) return toast.error("请输入账号和密码")
setBusy(true)
try {
const res = await api<{ success: boolean; username: string }>("adminLogin", { username: username.trim(), password })
localStorage.setItem("admin_username", res.username)
qc.setQueryData(["auth"], { logged_in: true, username: res.username })
toast.success("登录成功")
navigate("/", { replace: true })
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="admin-login"><section className="login-story"><div className="admin-brand"><div className="brand-mark"><Heart size={24}/></div><div><strong>我们 past</strong><span>管理后台</span></div></div><div className="login-story-main"><div className="login-art"><Heart size={100} strokeWidth={1}/></div><h1>共同记录,<br/>让陪伴有迹可循。</h1><p>管理每一个空间,照顾每一份记录。</p><div className="login-features"><span><Users size={18}/>用户与关系</span><span><NotebookPen size={18}/>生活与内容</span><span><Settings size={18}/>外观与设置</span></div></div></section><section className="login-form-area">
<Card className="login-card">
<CardHeader>
<div className="login-eyebrow">我们 past · 工作台</div>
<CardTitle className="text-2xl">欢迎回来</CardTitle>
<CardDescription>登录账号,继续管理你们的空间</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="u">账号</Label>
<Input id="u" placeholder="请输入管理员账号" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus />
</div>
<div className="space-y-1.5">
<Label htmlFor="p">密码</Label>
<Input id="p" type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<Button type="submit" size="lg" className="w-full" disabled={busy}>
{busy ? "登录中…" : "登录"}
</Button>
</form>
</CardContent>
</Card><p className="login-footnote">我们 past · 每一份日常都值得珍藏</p></section>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { RefreshCw } from "lucide-react"
import { api, daysSince, BREED, STAGE, STAGE_COLOR } from "@/lib/api"
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
import { CoupleDetailDialog } from "@/components/CoupleDetailDialog"
import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination"
import { Card, CardContent } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function PetsPage() {
const [breed, setBreed] = useState("all")
const [stage, setStage] = useState("all")
const [cid, setCid] = useState<number | null>(null)
const [page, setPage] = useState(1)
const q = useQuery({
queryKey: ["admin_pets", breed, stage, page],
queryFn: () => api<Paged>("adminPets", { breed: breed === "all" ? undefined : breed, stage: stage === "all" ? undefined : stage, page, page_size: PAGE_SIZE }, "GET"),
})
const pets = q.data?.items || []
const goFirst = <T,>(setter: (v: T) => void) => (v: T) => { setter(v); setPage(1) }
return (
<div>
<PageTitle count={q.data ? `共 ${q.data.total} 只` : undefined}>宠物管理</PageTitle>
<Card>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1"><Label>筛选品种</Label>
<Select value={breed} onValueChange={goFirst(setBreed)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all">全部品种</SelectItem>{Object.entries(BREED).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label>筛选阶段</Label>
<Select value={stage} onValueChange={goFirst(setStage)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all">全部阶段</SelectItem>{Object.entries(STAGE).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent></Select></div>
<Button variant="outline" onClick={() => q.refetch()}><RefreshCw className="size-4" />刷新</Button>
</div>
{q.isPending ? <Loading rows={5} /> : q.isError ? <ErrorBox error={q.error} /> : !pets.length ? <Empty icon="🐾" text="暂无宠物数据" /> : (
<div className="overflow-x-auto">
<Table>
<TableHeader><TableRow><TableHead>情侣</TableHead><TableHead>宠物名</TableHead><TableHead>品种</TableHead><TableHead>等级</TableHead><TableHead>阶段</TableHead><TableHead>属性</TableHead><TableHead>领养日</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableBody>
{pets.map((p) => (
<TableRow key={p.id} className="cursor-pointer" onClick={() => setCid(p.couple_id)}>
<TableCell>{p.user1_name || ""} & {p.user2_name || ""}</TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>{BREED[p.breed] || p.breed}</TableCell>
<TableCell>Lv.{p.level}</TableCell>
<TableCell><span className="rounded-full px-2.5 py-0.5 text-xs text-white" style={{ background: STAGE_COLOR[p.stage - 1] || STAGE_COLOR[0] }}>{STAGE[p.stage] || "未知"}</span></TableCell>
<TableCell className="whitespace-nowrap text-xs">😊{p.happiness} 🍖{p.hunger} ⚡{p.energy} 🧹{p.cleanliness}</TableCell>
<TableCell className="text-xs text-muted-foreground">{p.adopted_at || "-"}{p.adopted_at && <div className="text-pink-500">已领养{daysSince(p.adopted_at)}天</div>}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}><Button size="sm" variant="outline" onClick={() => setCid(p.couple_id)}>查看情侣</Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage} />
</div>
)}
</CardContent>
</Card>
<CoupleDetailDialog coupleId={cid} onClose={() => setCid(null)} />
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from "react"
import { toast } from "sonner"
import { api } from "@/lib/api"
import { PageTitle } from "@/components/common"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
export default function SettingsPage() {
const [username, setUsername] = useState("")
const [oldPw, setOldPw] = useState("")
const [newPw, setNewPw] = useState("")
const [fixResult, setFixResult] = useState("")
const [cleanResult, setCleanResult] = useState("")
const wrap = (fn: () => Promise<unknown>) => async () => {
try { await fn() } catch (e) { toast.error((e as Error).message) }
}
const changeUsername = wrap(async () => {
if (!username.trim()) return toast.error("用户名不能为空")
await api("adminChangeUsername", { new_username: username.trim() })
localStorage.setItem("admin_username", username.trim())
toast.success("用户名已更新")
setUsername("")
})
const changePassword = wrap(async () => {
if (newPw.length < 6) return toast.error("新密码至少6位")
await api("adminChangePassword", { old_password: oldPw, new_password: newPw })
toast.success("密码已更新")
setOldPw(""); setNewPw("")
})
const fixLastLogin = wrap(async () => {
const r = await api("adminFixLastLogin", null, "GET")
setFixResult(r.msg); toast.success(r.msg)
})
const cleanLocations = wrap(async () => {
const r = await api("adminCleanLocations", null, "GET")
setCleanResult(r.msg); toast.success(r.msg)
})
return (
<div>
<PageTitle>系统设置</PageTitle>
<div className="grid gap-5 md:grid-cols-2">
<Card>
<CardHeader><CardTitle className="text-base">修改用户名</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1"><Label>新用户名</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="输入新用户名" /></div>
<Button onClick={changeUsername}>保存</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">修改密码</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1"><Label>原密码</Label><Input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} /></div>
<div className="space-y-1"><Label>新密码</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="6位以上" /></div>
<Button onClick={changePassword}>保存</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">最后登录时间修复</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">将历史用户的最后登录时间补全为注册时间(仅对从未登录过的用户生效)</p>
<div className="flex items-center gap-3"><Button onClick={fixLastLogin}>一键补全</Button><span className="text-sm text-emerald-600">{fixResult}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">经纬度位置清理</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">清理历史写入的经纬度格式位置数据(形如"纬度,经度"),避免前端显示经纬度而非城市地址</p>
<div className="flex items-center gap-3"><Button onClick={cleanLocations}>一键清理</Button><span className="text-sm text-emerald-600">{cleanResult}</span></div>
</CardContent>
</Card>
</div>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { api, fmtTime } from "@/lib/api"
import { Avatar } from "@/components/ImagePreview"
import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination"
import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common"
import { Card, CardContent } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
const STATUS: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
pending: { label: "⏳ 待回应", variant: "outline" },
agreed: { label: "✅ 已同意(已解绑)", variant: "default" },
rejected: { label: "❌ 已拒绝", variant: "destructive" },
cancelled: { label: "↩️ 已撤销", variant: "secondary" },
expired: { label: "⏰ 已过期", variant: "secondary" },
}
type Action = { kind: "restore" | "delete"; coupleId: number; name: string; step: number }
export default function UnbindPage() {
const qc = useQueryClient()
const [filter, setFilter] = useState("all")
const [action, setAction] = useState<Action | null>(null)
const [page, setPage] = useState(1)
const q = useQuery({
queryKey: ["admin_unbind_requests", filter, page],
queryFn: () => api<Paged>("adminUnbindRequests", { status: filter === "all" ? undefined : filter, page, page_size: PAGE_SIZE }, "GET"),
})
const rows = q.data?.items || []
async function run() {
if (!action) return
// 删除数据需要二次确认(与旧版一致)
if (action.kind === "delete" && action.step === 0) return setAction({ ...action, step: 1 })
try {
const r = await api(action.kind === "restore" ? "admin_restore_couple" : "admin_delete_couple_data", { couple_id: action.coupleId })
toast.success(r.msg || "操作成功 ✅")
qc.invalidateQueries({ queryKey: ["admin_unbind_requests"] })
qc.invalidateQueries({ queryKey: ["admin_couples"] })
} catch (e) { toast.error((e as Error).message) } finally { setAction(null) }
}
return (
<div>
<PageTitle count={q.data ? `共 ${q.data.total} 条记录` : undefined}>解绑管理</PageTitle>
<Card>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1"><Label>筛选状态</Label>
<Select value={filter} onValueChange={(v) => { setFilter(v); setPage(1) }}><SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all">全部状态</SelectItem>{Object.entries(STATUS).map(([k, v]) => <SelectItem key={k} value={k}>{v.label}</SelectItem>)}</SelectContent></Select></div>
<Button variant="outline" onClick={() => q.refetch()}><RefreshCw className="size-4" />刷新</Button>
</div>
{q.isPending ? <Loading rows={5} /> : q.isError ? <ErrorBox error={q.error} /> : !rows.length ? <Empty icon="🔓" text="暂无解绑记录" /> : (
<div className="overflow-x-auto">
<Table>
<TableHeader><TableRow><TableHead>情侣</TableHead><TableHead>发起方</TableHead><TableHead>状态</TableHead><TableHead>发起时间</TableHead><TableHead>回应时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableBody>
{rows.map((r, i) => {
const name = `${r.user1_name || "?"} 💕 ${r.user2_name || "?"}`
const restored = r.status === "agreed" && r.couple_status === "active"
const st = STATUS[r.status]
return (
<TableRow key={r.id ?? `c${r.couple_id}-${i}`}>
<TableCell><div className="font-medium">{name}</div>{r.couple_space_id && <div className="text-xs text-muted-foreground">空间ID: {r.couple_space_id}</div>}</TableCell>
<TableCell>{r.requester_id ? <span className="flex items-center gap-1.5"><Avatar path={r.requester_avatar} name={r.requester_name} size={24} />{r.requester_name || `用户#${r.requester_uid}`}</span> : <span className="text-muted-foreground">-</span>}</TableCell>
<TableCell>{restored ? <Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">🔄 已恢复关系</Badge> : <Badge variant={st?.variant || "outline"}>{st?.label || r.status}</Badge>}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.responded_at)}</TableCell>
<TableCell>
{r.status === "agreed" && !restored ? (
<div className="flex gap-1.5">
<Button size="sm" className="bg-emerald-500 text-white hover:bg-emerald-600" onClick={() => setAction({ kind: "restore", coupleId: r.couple_id, name, step: 0 })}>🔄 恢复关系</Button>
<Button size="sm" variant="destructive" onClick={() => setAction({ kind: "delete", coupleId: r.couple_id, name, step: 0 })}>🗑️ 删除数据</Button>
</div>
) : restored ? <span className="text-xs text-emerald-600">✅ 已恢复</span>
: r.status === "pending" ? <span className="text-xs text-amber-600">⏳ 等待双方同意</span>
: <span className="text-xs text-muted-foreground">-</span>}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage} />
</div>
)}
</CardContent>
</Card>
<AlertDialog open={!!action} onOpenChange={(o) => !o && setAction(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{action?.kind === "restore" ? "🔄 恢复情侣关系" : action?.step === 1 ? "⚠️ 再次确认" : "⚠️ 永久删除情侣数据"}</AlertDialogTitle>
<AlertDialogDescription className="whitespace-pre-line">
{action?.kind === "restore"
? `确定要恢复「${action.name}」的情侣关系吗?\n所有数据将恢复,双方用户可继续使用。`
: action?.step === 1
? "此操作将彻底删除所有情侣数据。确定继续吗?"
: `确定要永久删除「${action?.name}」的所有情侣数据吗?\n\n此操作将:\n1. 删除所有情侣内容(日记、记忆日、相册、照片、歌曲、打卡等)\n2. 保留双方用户账号,可重新邀请绑定\n3. 删除情侣空间文件夹\n\n此操作不可恢复!`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant={action?.kind === "delete" ? "destructive" : "default"} onClick={(e) => { e.preventDefault(); run() }}>
{action?.kind === "restore" ? "确认恢复" : action?.step === 1 ? "确认删除" : "继续"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Search, X } from "lucide-react"
import { api, fmtTime } from "@/lib/api"
import { Avatar } from "@/components/ImagePreview"
import { PageTitle, Empty, Loading, ErrorBox, genderText } from "@/components/common"
import { UserDetailDialog, ResetPasswordDialog, DeleteUserDialog } from "@/components/UserDialogs"
import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function UsersPage() {
const [input, setInput] = useState("")
const [search, setSearch] = useState("")
const [bind, setBind] = useState("all")
const [sort, setSort] = useState("newest")
const [page, setPage] = useState(1)
const [detailId, setDetailId] = useState<number | null>(null)
const [resetUser, setResetUser] = useState<{ id: number; name: string } | null>(null)
const [delUser, setDelUser] = useState<{ id: number; name: string; hasPartner: boolean } | null>(null)
const q = useQuery({
queryKey: ["admin_users", search, bind, sort, page],
queryFn: () => api<Paged>("adminUsers", { search, bind: bind === "all" ? undefined : bind, sort: sort === "newest" ? undefined : sort, page, page_size: PAGE_SIZE }, "GET"),
})
const users = q.data?.items || []
const goFirst = <T,>(setter: (v: T) => void) => (v: T) => { setter(v); setPage(1) }
return (
<div>
<PageTitle count={q.data ? `共 ${q.data.total} 人` : undefined}>用户管理</PageTitle>
<Card>
<CardContent className="space-y-4">
<form className="flex flex-wrap items-end gap-3" onSubmit={(e) => { e.preventDefault(); goFirst(setSearch)(input.trim()) }}>
<div className="min-w-56 flex-1 space-y-1">
<Label>🔍 搜索用户</Label>
<div className="flex gap-2">
<Input value={input} onChange={(e) => setInput(e.target.value)} placeholder="输入用户ID或昵称…" />
<Button type="submit"><Search className="size-4" />搜索</Button>
<Button type="button" variant="outline" onClick={() => { setInput(""); goFirst(setSearch)("") }}><X className="size-4" />清除</Button>
</div>
</div>
<div className="space-y-1">
<Label>💞 情侣状态</Label>
<Select value={bind} onValueChange={goFirst(setBind)}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="bound">已绑定情侣</SelectItem>
<SelectItem value="unbound">未绑定情侣</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>⏰ 排序</Label>
<Select value={sort} onValueChange={goFirst(setSort)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="newest">最新注册</SelectItem>
<SelectItem value="last_login">最后登录</SelectItem>
</SelectContent>
</Select>
</div>
</form>
{q.isPending ? <Loading rows={6} /> : q.isError ? <ErrorBox error={q.error} /> : !users.length ? <Empty icon="👥" text="暂无用户" /> : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>用户</TableHead><TableHead>性别</TableHead><TableHead>年龄</TableHead><TableHead>生日</TableHead>
<TableHead>注册时间</TableHead><TableHead>最后登录</TableHead><TableHead>登录地址</TableHead><TableHead>IP</TableHead>
<TableHead>状态</TableHead><TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => {
const name = u.nickname || u.username
return (
<TableRow key={u.id} className="cursor-pointer" onClick={() => setDetailId(u.id)}>
<TableCell>
<div className="flex items-center gap-2.5">
<Avatar path={u.avatar} name={name} />
<div>
<div className="font-medium">{u.nickname || "-"}</div>
<div className="text-xs text-muted-foreground">#{u.user_id || "-"} · {u.openid ? `微信 · ${String(u.openid).slice(0, 12)}…` : "手机"}</div>
</div>
</div>
</TableCell>
<TableCell>{genderText(u.gender)}</TableCell>
<TableCell>{u.age > 0 ? `${u.age}岁` : "-"}</TableCell>
<TableCell>{u.birthday || "-"}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(u.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(u.last_login_at)}</TableCell>
<TableCell className="max-w-36 truncate text-xs text-muted-foreground" title={u.last_login_location || u.location}>{u.last_login_location || u.location || "-"}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{u.last_ip || "-"}</TableCell>
<TableCell><Badge variant={u.couple_id ? "default" : "outline"}>{u.couple_id ? "💕 已绑定" : "💭 未绑定"}</Badge></TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1.5">
{!u.openid && <Button size="sm" variant="outline" onClick={() => setResetUser({ id: u.id, name })}>🔑 重置密码</Button>}
<Button size="sm" variant="destructive" onClick={() => setDelUser({ id: u.id, name, hasPartner: !!u.couple_id })}>🗑️ 删除</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage} />
</div>
)}
</CardContent>
</Card>
<UserDetailDialog userId={detailId} onClose={() => setDetailId(null)} onDelete={(u) => { setDetailId(null); setDelUser(u) }} />
<ResetPasswordDialog user={resetUser} onClose={() => setResetUser(null)} />
<DeleteUserDialog user={delUser} onClose={() => setDelUser(null)} />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const source = fs.readFileSync(require('node:path').join(__dirname, '../src/lib/api.ts'), 'utf8');
function client(fetch) {
const exports = {};
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, module: { exports }, fetch, URLSearchParams, FormData, console });
return exports;
}
test('admin paths, query, JSON request and response fields use camelCase on the wire',async()=>{
const calls=[];
const {api}=client(async(url,init)=>{calls.push({url,init});return {ok:true,status:200,json:async()=>({totalUsers:12,items:[{userId:'87',displayId:'520087'}]})};});
const result=await api('adminUsers',{page_size:20},'GET');
await api('adminUpdateUser',{user_id:87,profile_data:{birth_date:'2026-10-05'}});
assert.equal(calls[0].url,'/api/adminUsers?pageSize=20');
assert.equal(calls[0].init.cache,'no-store');
assert.equal(calls[0].init.credentials,'same-origin');
assert.equal(result.total_users,12);assert.equal(result.items[0].user_id,'87');
assert.deepEqual(JSON.parse(calls[1].init.body),{userId:87,profileData:{birthDate:'2026-10-05'}});
});
test('admin multipart uploads also rename metadata fields',async()=>{
let upload;
const {apiUpload}=client(async(url,init)=>{upload={url,init};return {ok:true,status:200,json:async()=>({success:true,backgroundPath:'uploads/backgrounds/a.jpg'})};});
const form=new FormData();form.append('couple_id','9');form.append('bg','image-data');
const result=await apiUpload('adminSetGlobalBg',form);
assert.equal(upload.url,'/api/adminSetGlobalBg');assert.equal(upload.init.body.get('coupleId'),'9');assert.equal(upload.init.body.get('bg'),'image-data');assert.equal(result.background_path,'uploads/backgrounds/a.jpg');
});
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"paths": { "@/*": ["./src/*"] },
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import path from "node:path"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
// 构建产物由 Go 通过 embed 打进二进制,路径固定为 /backend/admin/
export default defineConfig({
base: "/backend/admin/",
plugins: [react(), tailwindcss()],
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
server: {
port: 5173,
// 本地开发:API 与上传文件转发到 Go 服务
proxy: { "/api": "http://127.0.0.1:8080", "/backend/api": "http://127.0.0.1:8080", "/backend/uploads": "http://127.0.0.1:8080" },
},
build: { outDir: "dist", emptyOutDir: true },
})

Some files were not shown because too many files have changed in this diff Show More