init: initial commit
This commit is contained in:
Generated
+10
@@ -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
|
||||||
Generated
+10
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+9
@@ -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
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
run/
|
||||||
|
config.yaml
|
||||||
|
web/admin/node_modules/
|
||||||
|
# web/admin/dist 会被 embed,构建后需存在;发布时用 make all 生成
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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 每次请求执行)。
|
||||||
@@ -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("服务已停止")
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 精修
|
||||||
|
|
||||||
|
小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。
|
||||||
@@ -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;
|
||||||
|
# }
|
||||||
@@ -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
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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": "已解除绑定"})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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=数据已删除'"},
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+7630
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 }
|
||||||
@@ -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}}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { cn } from "cn"
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user