同步小程序与管理端更新,完善抽烟记录和解绑冷静期

This commit is contained in:
2026-10-09 22:15:51 +08:00
parent a68a4ef411
commit b10fc3c4ac
149 changed files with 3794 additions and 589 deletions
@@ -0,0 +1,76 @@
const { api, getImageUrl } = require('../../utils/request');
const cache = require('../../utils/data-cache');
const titles = {consume:'抽一支 · 打卡',purchase:'买烟',receive:'别人递烟',receive_smoke:'收到并抽一支',adjust:'调整库存',give:'递给别人一支',undo:'撤销记录'};
Component({
properties: { detailed: Boolean },
data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:0, lastLabel:'还没有记录', daily:[], logs:[], ownerName:'', ownerAvatar:'', ownerInitial:'我' },
lifetimes: { attached(){this.load();} },
pageLifetimes: { show(){this.load();} },
methods: {
async load(force) {
if(this._loading) return this._loading;
if(force)cache.invalidate(['smokingInfo']);
this.setData({error:false});
const target = this.data.viewUserId;
this._loading=Promise.all([api.smokingInfo(target), api.coupleInfo().catch(()=>null)]).then(([info,profile])=>{if(target===this.data.viewUserId){this._profile=profile;this.applyInfo(info);}}).catch(()=>this.setData({error:true})).finally(()=>{this._loading=null;if(target===this.data.viewUserId)this.setData({loading:false});});
return this._loading;
},
applyInfo(info) {
const logs=(info.logs||[]).map(log=>({...log,undone:log.undone === true || Number(log.undone) === 1,title:titles[log.kind]||log.kind,change:Number(log.delta)>0?'+'+log.delta:String(log.delta),time:String(log.created_at||'').slice(5,16),money:Number(log.amount_cents)>0?(Number(log.amount_cents)/100).toFixed(2):''}));
let lastLabel='还没有记录';
if(info.last_time){const minutes=Math.max(0,Math.floor((Date.now()-new Date(String(info.last_time).replace(/-/g,'/')).getTime())/60000));lastLabel=minutes<60?'距上次 '+minutes+' 分钟':minutes<1440?'距上次 '+Math.floor(minutes/60)+' 小时':'距上次 '+Math.floor(minutes/1440)+' 天';}
const map={};(info.daily||[]).forEach(row=>{map[row.day]=Number(row.count);});const daily=[];
for(let ago=6;ago>=0;ago--){const day=new Date();day.setDate(day.getDate()-ago);const key=day.getFullYear()+'-'+String(day.getMonth()+1).padStart(2,'0')+'-'+String(day.getDate()).padStart(2,'0');daily.push({day:key,label:ago===0?'今天':(day.getMonth()+1)+'/'+day.getDate(),count:map[key]||0});}
const max=Math.max(1,...daily.map(day=>day.count));daily.forEach(day=>day.height=Math.max(4,day.count/max*100));
const profile=this._profile||{};
const ownerId=Number(info.user_id || this.data.viewUserId || wx.getStorageSync('userId'));
const owner=[profile.partner1,profile.partner2].find(user=>user && Number(user.id)===ownerId)||{};
const ownerName=owner.nickname||info.owner_name||owner.username||(info.read_only?'TA':'我');
const ownerAvatar=getImageUrl(owner.avatar||info.owner_avatar||'');
this.setData({info,logs,daily,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false});
},
avatarFailed(){this.setData({ownerAvatar:''});},
retry(){this.load(true);},
openDetail(){if(!this.properties.detailed)wx.navigateTo({url:'/pages/smoking-detail/smoking-detail'});},
async switchOwner(e){if(this.data.saving)return;this.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,loading:true,info:null});if(this._loading)await this._loading;await this.load();},
replenish(){if(this.data.saving)return;this.setData({sheet:true,mode:'purchase',quantity:'1',packSize:'20',amount:'',note:''});},
mode(e){if(this.data.saving)return;const mode=e.currentTarget.dataset.mode;this.setData({mode,quantity:mode==='adjust'?String(this.data.info.stock):'1',amount:'',note:''});},
input(e){if(!this.data.saving)this.setData({[e.currentTarget.dataset.field]:e.detail.value});},
close(){if(!this.data.saving)this.setData({sheet:false});},
preventMove(){},
smoke(){return this.act({kind:'consume',quantity:1});},
receiveSmoke(){return this.act({kind:'receive_smoke',quantity:1});},
giveSmoke(){
if(this.data.saving || !this.data.info || this.data.info.read_only || !this.data.info.available)return;
if(Number(this.data.info.stock)<1)return wx.showToast({title:'库存不足,请先补充库存',icon:'none'});
return this.act({kind:'give',quantity:1});
},
submit() {
const mode=this.data.mode;
const quantityText=this.data.quantity.trim(),packText=this.data.packSize.trim();
if(!/^\d+$/.test(quantityText)||(mode==='purchase'&&!/^\d+$/.test(packText)))return wx.showToast({title:'请填写整数数量',icon:'none'});
let quantity=Number(quantityText);if(mode==='purchase'){if(Number(packText)<1||Number(packText)>100)return wx.showToast({title:'每包数量应为1到100支',icon:'none'});quantity*=Number(packText);}
if(quantity>(mode==='adjust'?1000000:10000)||(mode!=='adjust'&&quantity<1))return wx.showToast({title:'数量超出范围',icon:'none'});
const money=this.data.amount.trim();if(mode==='purchase'&&money&&!/^\d+(\.\d{1,2})?$/.test(money))return wx.showToast({title:'金额最多两位小数',icon:'none'});
const amount=mode==='purchase'?Math.round(Number(money||0)*100):0;
if(amount>100000000)return wx.showToast({title:'金额超出范围',icon:'none'});
return this.act({kind:mode,quantity,amount_cents:amount,note:this.data.note.trim()});
},
undo(e){if(this.data.saving)return;wx.showModal({title:'撤销这条记录?',content:'恢复该操作前的库存变化和抽烟次数。赠出一支仅扣库存,撤销会加回一支;“收到并抽一支”会同时撤销收到和抽烟。',success:res=>{if(res.confirm)this.act({kind:'undo',original_id:Number(e.currentTarget.dataset.id),quantity:0});}});},
async act(payload) {
if(this.data.saving||!this.data.info||this.data.info.read_only||!this.data.info.available)return;
const signature=JSON.stringify(payload);
if(!this._pendingAction||this._pendingAction.signature!==signature)this._pendingAction={signature,key:'smoke-'+Date.now()+'-'+Math.random().toString(36).slice(2)};
this.setData({saving:true});
try {
const result=await api.smokingAction({...payload,request_key:this._pendingAction.key});
if(!result.success)throw result;
this._pendingAction=null;this.applyInfo(result.info);this.setData({sheet:false});this.triggerEvent('updated');wx.showToast({title:'已记录',icon:'none'});
} catch(error) {
// 明确的业务拒绝不会提交;网络不确定时保留请求标识,重试不会重复扣减。
if(error&&error._statusCode&&error._statusCode!==500)this._pendingAction=null;
wx.showToast({title:error&&error.error||'请求失败,请重试',icon:'none'});
} finally {this.setData({saving:false});}
}
}
});
@@ -0,0 +1 @@
{"component":true,"usingComponents":{}}
@@ -0,0 +1,26 @@
<view class="smoke-card">
<view class="smoke-head" bindtap="openDetail"><view><text class="smoke-eyebrow">默认事件 · 按支记录</text><text class="smoke-title">抽烟记录</text></view><text class="smoke-link" wx:if="{{!detailed}}">明细 ›</text></view>
<view class="owners" wx:if="{{detailed && info && info.partner_id}}"><view class="owner {{!viewUserId ? 'selected' : ''}}" data-user="0" bindtap="switchOwner">我的记录</view><view class="owner {{viewUserId ? 'selected' : ''}}" data-user="{{info.partner_id}}" bindtap="switchOwner">TA的记录</view></view>
<view wx:if="{{loading}}" class="smoke-hint">正在加载记录…</view>
<view wx:elif="{{error}}" class="smoke-hint" bindtap="retry">记录加载失败,点击重试</view>
<block wx:elif="{{info}}">
<view class="smoke-stats"><view><text class="smoke-number">{{info.today_count}}<text>支</text></text><text class="smoke-label">今日已抽</text></view><view><text class="smoke-number">{{info.stock}}<text>支</text></text><text class="smoke-label">剩余库存</text></view></view>
<text class="smoke-hint">{{info.read_only ? info.owner_name + ' · 仅查看' : lastLabel}}</text>
<text class="smoke-hint" wx:if="{{!info.available}}">绑定伴侣后可记录个人库存和打卡</text>
<block wx:elif="{{!info.read_only}}">
<view class="smoke-actions"><button class="smoke-primary" disabled="{{saving || info.stock < 1}}" bindtap="smoke">抽一支,打卡</button><button class="smoke-secondary" disabled="{{saving}}" bindtap="replenish">+ 补充库存</button></view>
<view class="smoke-inline-actions"><view class="smoke-inline" bindtap="receiveSmoke">{{saving ? '正在保存…' : '别人递来一支,当场抽掉 →'}}</view><view class="smoke-inline smoke-give {{saving || info.stock < 1 ? 'smoke-give-disabled' : ''}}" bindtap="giveSmoke">递给别人一支 →</view></view>
<view class="smoke-hint" wx:if="{{info.stock < 1}}">还没有库存,先录入已有的烟或补充库存</view>
</block>
<view wx:if="{{!detailed && logs.length && !info.read_only}}" class="smoke-recent"><text>{{logs[0].title}} · {{logs[0].time}}</text><text wx:if="{{!logs[0].undone && logs[0].kind !== 'undo'}}" data-id="{{logs[0].id}}" bindtap="undo">撤销</text></view>
<block wx:if="{{detailed}}">
<view class="section-title">最近七天<text>累计 {{info.total_count}} 支</text></view>
<view class="week-chart"><view class="chart-day" wx:for="{{daily}}" wx:key="day"><text>{{item.count}}</text><view class="chart-track"><view class="chart-bar" style="height:{{item.height}}%" /></view><text>{{item.label}}</text></view></view>
<view class="section-title">库存与打卡流水<text>最近 50 条</text></view>
<view class="smoke-hint" wx:if="{{!logs.length}}">从补充库存开始,记录每一次消耗</view>
<view class="ledger {{item.undone ? 'undone' : ''}}" wx:for="{{logs}}" wx:key="id"><view class="ledger-avatar"><image wx:if="{{ownerAvatar}}" src="{{ownerAvatar}}" mode="aspectFill" binderror="avatarFailed" /><text wx:else>{{ownerInitial}}</text></view><view class="ledger-main"><view class="ledger-owner">{{ownerName}}</view><text>{{item.title}}{{item.undone ? ' · 已撤销' : ''}}</text><text>{{item.time}}{{item.money ? ' · ¥' + item.money : ''}}</text><text wx:if="{{item.note}}">{{item.note}}</text></view><view class="ledger-right"><text>{{item.change}} 支 · 余 {{item.balance}}</text><text wx:if="{{!info.read_only && !item.undone && item.kind !== 'undo'}}" data-id="{{item.id}}" bindtap="undo">撤销</text></view></view>
</block>
</block>
</view>
<view class="smoke-mask" wx:if="{{sheet}}" catchtouchmove="preventMove"><view class="smoke-backdrop" bindtap="close" /><view class="smoke-sheet"><view class="sheet-grip" /><view class="sheet-head"><text>补充与调整库存</text><text bindtap="close">关闭</text></view><view class="sheet-tabs"><view class="{{mode === 'purchase' ? 'selected' : ''}}" data-mode="purchase" bindtap="mode">买烟</view><view class="{{mode === 'receive' ? 'selected' : ''}}" data-mode="receive" bindtap="mode">别人递烟</view><view class="{{mode === 'adjust' ? 'selected' : ''}}" data-mode="adjust" bindtap="mode">调整库存</view></view>
<scroll-view class="sheet-body" scroll-y><text class="field-label">{{mode === 'purchase' ? '购买包数' : (mode === 'adjust' ? '盘点后实际剩余(支)' : '收到数量(支)')}}</text><input class="smoke-input" type="number" value="{{quantity}}" data-field="quantity" bindinput="input" disabled="{{saving}}" /><block wx:if="{{mode === 'purchase'}}"><text class="field-label">每包支数</text><input class="smoke-input" type="number" value="{{packSize}}" data-field="packSize" bindinput="input" disabled="{{saving}}" /><text class="field-label">购买金额(选填,元)</text><input class="smoke-input" type="digit" value="{{amount}}" data-field="amount" bindinput="input" disabled="{{saving}}" /></block><text class="field-label">备注(选填)</text><input class="smoke-input" value="{{note}}" maxlength="200" data-field="note" bindinput="input" disabled="{{saving}}" /><text class="smoke-hint">{{mode === 'adjust' ? '库存将调整为填写的数量,不计入抽烟打卡。' : '只增加库存,不计入抽烟打卡。'}}</text></scroll-view><button class="smoke-primary sheet-save" loading="{{saving}}" disabled="{{saving}}" bindtap="submit">{{mode === 'adjust' ? '确认调整' : '确认补充'}}</button></view></view>
@@ -0,0 +1,14 @@
.smoke-card{padding:32rpx;border:1rpx solid #F1DCE4;border-radius:28rpx;background:#FFFCFD;margin-bottom:28rpx;color:#352E2A}.smoke-head{display:flex;justify-content:space-between;align-items:center}.smoke-eyebrow{display:block;font-size:21rpx;color:#A39386;margin-bottom:10rpx}.smoke-title{font-size:33rpx;font-weight:600}.smoke-link{font-size:24rpx;color:#B74768}.smoke-stats{display:flex;margin:30rpx 0 20rpx}.smoke-stats>view{flex:1}.smoke-stats>view+view{padding-left:32rpx;border-left:1rpx solid #F1DCE4}.smoke-number{display:block;font-size:54rpx;color:#B74768;line-height:1.2}.smoke-number text{font-size:23rpx;margin-left:10rpx;color:#A39386}.smoke-label{display:block;font-size:23rpx;color:#85776D;margin-top:10rpx}.smoke-hint{display:block;font-size:22rpx;color:#A39386;line-height:1.7;margin:12rpx 0}.smoke-actions{display:flex;gap:16rpx;margin-top:24rpx}.smoke-primary,.smoke-secondary{flex:1;margin:0;border-radius:20rpx;font-size:25rpx;height:88rpx;min-height:88rpx;padding:0;display:flex;align-items:center;justify-content:center;line-height:normal;box-sizing:border-box}.smoke-primary{background:#B74768;color:#FFF}.smoke-secondary{background:#FFF0F5;color:#B74768;border:1rpx solid #F1DCE4}button::after{border:0}.smoke-primary[disabled]{background:#F0E2E8;color:#A39386}.smoke-inline{font-size:22rpx;color:#B74768;padding:24rpx 0 10rpx}.smoke-recent{display:flex;justify-content:space-between;gap:16rpx;border-top:1rpx solid #F1DCE4;margin-top:20rpx;padding-top:20rpx;font-size:22rpx;color:#A39386}.smoke-recent text+text{color:#B74768}.owners,.sheet-tabs{display:flex;gap:10rpx;margin:24rpx 0}.owner,.sheet-tabs>view{flex:1;text-align:center;background:#FFF5F8;color:#85776D;padding:18rpx 8rpx;border-radius:16rpx;font-size:24rpx}.owners .selected,.sheet-tabs .selected{background:#F4E2E3;color:#B74768;font-weight:600}.section-title{font-size:27rpx;font-weight:600;display:flex;justify-content:space-between;margin:40rpx 0 22rpx}.section-title text{font-size:20rpx;color:#A39386;font-weight:400}.week-chart{display:flex;gap:10rpx}.chart-day{flex:1;text-align:center;font-size:20rpx;color:#A39386}.chart-track{height:110rpx;display:flex;align-items:flex-end;justify-content:center;margin:12rpx 0}.chart-bar{width:24rpx;border-radius:8rpx;background:#D5A6AE}.ledger{display:flex;justify-content:space-between;gap:20rpx;padding:24rpx 0;border-top:1rpx solid #F1DCE4;font-size:24rpx}.ledger-main{flex:1;min-width:0}.ledger-main text{display:block;overflow-wrap:break-word}.ledger-main text+text{font-size:21rpx;color:#A39386;margin-top:8rpx}.ledger-right{text-align:right;font-size:22rpx}.ledger-right text{display:block}.ledger-right text+text{color:#B74768;padding-top:14rpx}.undone{opacity:.55}.smoke-mask{position:fixed;inset:0;z-index:1300;display:flex;align-items:flex-end}.smoke-backdrop{position:absolute;inset:0;background:rgba(53,46,42,.35)}.smoke-sheet{position:relative;width:100%;max-height:90vh;background:#FFFCFD;border-radius:40rpx 40rpx 0 0;padding:16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));box-sizing:border-box}.sheet-grip{width:64rpx;height:7rpx;background:#EBD5DE;border-radius:8rpx;margin:0 auto 26rpx}.sheet-head{display:flex;justify-content:space-between;font-size:30rpx;font-weight:600}.sheet-head text+text{font-size:24rpx;color:#B74768;font-weight:400}.sheet-body{height:600rpx;max-height:52vh}.field-label{display:block;font-size:24rpx;color:#85776D;margin:20rpx 0 12rpx}.smoke-input{height:84rpx;width:100%;background:#FFF5F8;border:1rpx solid #F1DCE4;border-radius:16rpx;padding:0 24rpx;box-sizing:border-box;font-size:27rpx;color:#352E2A}.sheet-save{width:100%;margin-top:24rpx}
.ledger-avatar{width:54rpx;height:54rpx;flex-shrink:0;border-radius:50%;overflow:hidden;background:#FADCE7;color:#B74768;display:flex;align-items:center;justify-content:center;font-size:24rpx}
.ledger-avatar image{width:100%;height:100%}
.ledger-owner{font-size:24rpx;color:#64424D;margin-bottom:10rpx;overflow-wrap:break-word}
@import '../../styles/button-theme.wxss';
.smoke-inline-actions{display:flex;align-items:center;gap:20rpx}
.smoke-inline-actions .smoke-inline:first-child{flex:1;min-width:0}
.smoke-give{flex-shrink:0;text-align:right}
.smoke-give-disabled{color:#A39386;opacity:.65}
@import '../../styles/typography.wxss';