diff --git a/deploy/.env.example b/deploy/.env.example index 4dc99ef..9f75c14 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -32,7 +32,11 @@ MINIO_PUBLIC_BASE_URL=https://res.sundynix.cn # ── 登录鉴权 ── JWT_SECRET=改成随机长字符串 -JWT_EXPIRE_HOURS=168 +# access token 有效期(小时)。短一点更安全,过期由前端拿 refresh token 静默续期 +JWT_EXPIRE_HOURS=2 +# 刷新令牌有效期:小程序 30 天、后台 7 天。到期才需要重新登录 +JWT_REFRESH_EXPIRE_HOURS=720 +JWT_ADMIN_REFRESH_EXPIRE_HOURS=168 # 生产必须 false,否则任何人都能用昵称直接登录 AUTH_DEV_LOGIN=false diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 60ccca8..f0908ed 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -38,7 +38,10 @@ services: PETS_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:?} PETS_JWT_SECRET: ${JWT_SECRET:?} - PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-168} + # access token 2 小时;过期由前端拿 refresh token 静默续期 + PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2} + PETS_JWT_REFRESH_EXPIRE_HOURS: ${JWT_REFRESH_EXPIRE_HOURS:-720} + PETS_JWT_ADMIN_REFRESH_EXPIRE_HOURS: ${JWT_ADMIN_REFRESH_EXPIRE_HOURS:-168} PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?} PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?} diff --git a/pets-be/README.md b/pets-be/README.md index 725fd80..0cd3e4b 100644 --- a/pets-be/README.md +++ b/pets-be/README.md @@ -47,3 +47,18 @@ make admin-dev # vite dev server :5173,已代理 /api → :8080 ## 登录鉴权 默认 `auth.dev_login: true`,`POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。 接真微信:在 `configs/config.yaml` 填 `wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。 + +### access token + refresh token +登录接口返回 `token`(access) 与 `refresh_token` 两个令牌: + +- **access token**:JWT,默认 **2 小时**(`jwt.expire_hours`),放 `Authorization: Bearer`,服务端无状态校验。 +- **refresh token**:随机串,落库 `sundynix_refresh_tokens`(**只存 sha256**)。小程序 30 天、后台 7 天。 +- 续期:`POST /api/auth/refresh` / `POST /api/admin/refresh`,入参 `{"refresh_token":"..."}`,返回新的一对令牌。 +- 退出:`POST /api/auth/logout` / `POST /api/admin/logout` 作废该刷新令牌。 + +安全约定: +- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。 +- 主动退出、被连坐吊销的令牌不享受宽限期。 +- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。 + +前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。 diff --git a/pets-be/cmd/server/main.go b/pets-be/cmd/server/main.go index d15bae1..648b518 100644 --- a/pets-be/cmd/server/main.go +++ b/pets-be/cmd/server/main.go @@ -40,9 +40,6 @@ func main() { log.Printf("AI provider: %s", engine.ProviderName()) svc := service.New(db, st, cfg, engine) - if err := svc.SeedCareTemplates(); err != nil { - log.Fatalf("seed care templates: %v", err) - } if err := svc.SeedCommunityBot(); err != nil { log.Fatalf("seed community bot: %v", err) } diff --git a/pets-be/internal/admin/dist/assets/index-BH08rQ_o.js b/pets-be/internal/admin/dist/assets/index-BH08rQ_o.js new file mode 100644 index 0000000..3a10f21 --- /dev/null +++ b/pets-be/internal/admin/dist/assets/index-BH08rQ_o.js @@ -0,0 +1,63 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function ee(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function te(e,t){return ee(e.type,t,e.props)}function ne(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function E(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var D=/\/+/g;function O(e,t){return typeof e==`object`&&e&&e.key!=null?E(``+e.key):t.toString(36)}function re(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ie(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ie(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+O(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(D,`$&/`)+`/`),ie(o,r,i,``,function(e){return e})):o!=null&&(ne(o)&&(o=te(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(D,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=u()})),f=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,ne());else{var t=n(l);t!==null&&O(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function ee(){return g?!0:!(e.unstable_now()-Tt&&ee());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&O(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ne():S=!1}}}var ne;if(typeof y==`function`)ne=function(){y(te)};else if(typeof MessageChannel<`u`){var E=new MessageChannel,D=E.port2;E.port1.onmessage=te,ne=function(){D.postMessage(null)}}else ne=function(){_(te,0)};function O(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,O(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,ne()))),r},e.unstable_shouldYield=ee,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),p=o(((e,t)=>{t.exports=f()})),m=o((e=>{var t=d();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=m()})),g=o((e=>{var t=p(),n=d(),r=h();function i(e){var t=`https://react.dev/errors/`+e;if(1le||(e.current=ce[le],ce[le]=null,le--)}function N(e,t){le++,ce[le]=e.current,e.current=t}var ue=j(null),de=j(null),fe=j(null),pe=j(null);function me(e,t){switch(N(fe,t),N(de,e),N(ue,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}M(ue),N(ue,e)}function he(){M(ue),M(de),M(fe)}function ge(e){e.memoizedState!==null&&N(pe,e);var t=ue.current,n=Hd(t,e.type);t!==n&&(N(de,e),N(ue,n))}function _e(e){de.current===e&&(M(ue),M(de)),pe.current===e&&(M(pe),Qf._currentValue=se)}var ve,ye;function be(e){if(ve===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);ve=t&&t[1]||``,ye=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{xe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?be(n):``}function Ce(e,t){switch(e.tag){case 26:case 27:case 5:return be(e.type);case 16:return be(`Lazy`);case 13:return e.child!==t&&t!==null?be(`Suspense Fallback`):be(`Suspense`);case 19:return be(`SuspenseList`);case 0:case 15:return Se(e.type,!1);case 11:return Se(e.type.render,!1);case 1:return Se(e.type,!0);case 31:return be(`Activity`);default:return``}}function we(e){try{var t=``,n=null;do t+=Ce(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var Te=Object.prototype.hasOwnProperty,Ee=t.unstable_scheduleCallback,De=t.unstable_cancelCallback,Oe=t.unstable_shouldYield,ke=t.unstable_requestPaint,Ae=t.unstable_now,je=t.unstable_getCurrentPriorityLevel,Me=t.unstable_ImmediatePriority,Ne=t.unstable_UserBlockingPriority,Pe=t.unstable_NormalPriority,Fe=t.unstable_LowPriority,Ie=t.unstable_IdlePriority,Le=t.log,Re=t.unstable_setDisableYieldValue,ze=null,Be=null;function Ve(e){if(typeof Le==`function`&&Re(e),Be&&typeof Be.setStrictMode==`function`)try{Be.setStrictMode(ze,e)}catch{}}var He=Math.clz32?Math.clz32:Ge,Ue=Math.log,We=Math.LN2;function Ge(e){return e>>>=0,e===0?32:31-(Ue(e)/We|0)|0}var Ke=256,qe=262144,Je=4194304;function Ye(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Xe(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ye(n))):i=Ye(o):i=Ye(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ye(n))):i=Ye(o)):i=Ye(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ze(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Qe(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function $e(){var e=Je;return Je<<=1,!(Je&62914560)&&(Je=4194304),e}function et(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function tt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function nt(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),hn=!1;if(mn)try{var gn={};Object.defineProperty(gn,"passive",{get:function(){hn=!0}}),window.addEventListener(`test`,gn,gn),window.removeEventListener(`test`,gn,gn)}catch{hn=!1}var _n=null,vn=null,yn=null;function bn(){if(yn)return yn;var e,t=vn,n=t.length,r,i=`value`in _n?_n.value:_n.textContent,a=i.length;for(e=0;e=Qn),tr=` `,nr=!1;function rr(e,t){switch(e){case`keyup`:return Xn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function ir(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var ar=!1;function or(e,t){switch(e){case`compositionend`:return ir(t);case`keypress`:return t.which===32?(nr=!0,tr):null;case`textInput`:return e=t.data,e===tr&&nr?null:e;default:return null}}function sr(e,t){if(ar)return e===`compositionend`||!Zn&&rr(e,t)?(e=bn(),yn=vn=_n=null,ar=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=kr(n)}}function jr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?jr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Mr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Vt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Vt(e.document)}return t}function Nr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Pr=mn&&`documentMode`in document&&11>=document.documentMode,Fr=null,Ir=null,Lr=null,Rr=!1;function zr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Rr||Fr==null||Fr!==Vt(r)||(r=Fr,`selectionStart`in r&&Nr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Lr&&Or(Lr,r)||(Lr=r,r=Td(Ir,`onSelect`),0>=o,i-=o,Ai=1<<32-He(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),I&&Mi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),I&&Mi(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return I&&Mi(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),I&&Mi(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ne&&ja(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ra(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=_i(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=gi(o.type,o.key,o.props,null,e.mode,c),Ra(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=bi(o,e.mode,c),c.return=e,e=c}return s(e);case ne:return o=ja(o),b(e,r,o,c)}if(oe(o))return h(e,r,o,c);if(re(o)){if(l=re(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,La(o),c);if(o.$$typeof===C)return b(e,r,aa(e,o),c);za(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=vi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ia=0;var i=b(e,t,n,r);return Fa=null,i}catch(t){if(t===Ta||t===Da)throw t;var a=pi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Va=Ba(!0),Ha=Ba(!1),Ua=!1;function Wa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ga(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ka(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function qa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Y&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ui(e),li(e,null,n),t}return oi(e,r,t,n),ui(e)}function Ja(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}function Ya(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Xa=!1;function Za(){if(Xa){var e=ga;if(e!==null)throw e}}function Qa(e,t,n,r){Xa=!1;var i=e.updateQueue;Ua=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(Z&f)===f:(r&f)===f){f!==0&&f===ha&&(Xa=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=m({},d,f);break a;case 2:Ua=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Ul|=o,e.lanes=o,e.memoizedState=d}}function $a(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function eo(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=k.T,s={};k.T=s,Rs(e,!1,t,n);try{var c=i(),l=k.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ls(e,t,ya(c,r),du(e)):Ls(e,t,r,du(e))}catch(n){Ls(e,t,{then:function(){},status:`rejected`,reason:n},du())}finally{A.p=a,o!==null&&s.types!==null&&(o.types=s.types),k.T=o}}function Ds(){}function Os(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=ks(e).queue;Es(e,a,t,se,n===null?Ds:function(){return As(e),n(r)})}function ks(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:se,baseState:se,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zo,lastRenderedState:se},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function As(e){var t=ks(e);t.next===null&&(t=e.alternate.memoizedState),Ls(e,t.next.queue,{},du())}function js(){return ia(Qf)}function Ms(){return Po().memoizedState}function Ns(){return Po().memoizedState}function Ps(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=du();e=Ka(n);var r=qa(t,e,n);r!==null&&(pu(r,t,n),Ja(r,t,n)),t={cache:da()},e.payload=t;return}t=t.return}}function Fs(e,t,n){var r=du();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},zs(e)?Bs(t,n):(n=si(e,t,n,r),n!==null&&(pu(n,e,r),Vs(n,t,r)))}function Is(e,t,n){Ls(e,t,n,du())}function Ls(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(zs(e))Bs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Dr(s,o))return oi(e,t,i,0),Il===null&&ai(),!1}catch{}if(n=si(e,t,i,r),n!==null)return pu(n,e,r),Vs(n,t,r),!0}return!1}function Rs(e,t,n,r){if(r={lane:2,revertLane:ud(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},zs(e)){if(t)throw Error(i(479))}else t=si(e,n,r,2),t!==null&&pu(t,e,2)}function zs(e){var t=e.alternate;return e===L||t!==null&&t===L}function Bs(e,t){bo=yo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Vs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}var Hs={readContext:ia,use:Lo,useCallback:Eo,useContext:Eo,useEffect:Eo,useImperativeHandle:Eo,useLayoutEffect:Eo,useInsertionEffect:Eo,useMemo:Eo,useReducer:Eo,useRef:Eo,useState:Eo,useDebugValue:Eo,useDeferredValue:Eo,useTransition:Eo,useSyncExternalStore:Eo,useId:Eo,useHostTransitionStatus:Eo,useFormState:Eo,useActionState:Eo,useOptimistic:Eo,useMemoCache:Eo,useCacheRefresh:Eo};Hs.useEffectEvent=Eo;var Us={readContext:ia,use:Lo,useCallback:function(e,t){return No().memoizedState=[e,t===void 0?null:t],e},useContext:ia,useEffect:ps,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ds(4194308,4,ys.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ds(4194308,4,e,t)},useInsertionEffect:function(e,t){ds(4,2,e,t)},useMemo:function(e,t){var n=No();t=t===void 0?null:t;var r=e();if(xo){Ve(!0);try{e()}finally{Ve(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=No();if(n!==void 0){var i=n(t);if(xo){Ve(!0);try{n(t)}finally{Ve(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Fs.bind(null,L,e),[r.memoizedState,e]},useRef:function(e){var t=No();return e={current:e},t.memoizedState=e},useState:function(e){e=Yo(e);var t=e.queue,n=Is.bind(null,L,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:xs,useDeferredValue:function(e,t){return ws(No(),e,t)},useTransition:function(){var e=Yo(!1);return e=Es.bind(null,L,e.queue,!0,!1),No().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=L,a=No();if(I){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Il===null)throw Error(i(349));Z&127||Wo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ps(Ko.bind(null,r,o,e),[e]),r.flags|=2048,ls(9,{destroy:void 0},Go.bind(null,r,o,n,t),null),n},useId:function(){var e=No(),t=Il.identifierPrefix;if(I){var n=ji,r=Ai;n=(r&~(1<<32-He(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=So++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[dt]=t,o[ft]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Pc(t)}}return zc(t),Fc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=fe.current,Wi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=F,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[dt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||jd(e.nodeValue,n)),e||Vi(t,!0)}else e=Bd(e).createTextNode(r),e[dt]=t,t.stateNode=e}return zc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Wi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[dt]=t}else Gi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;zc(t),e=!1}else n=Ki(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(po(t),t):(po(t),null);if(t.flags&128)throw Error(i(558))}return zc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Wi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[dt]=t}else Gi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;zc(t),a=!1}else a=Ki(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(po(t),t):(po(t),null)}return po(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Lc(t,t.updateQueue),zc(t),null);case 4:return he(),e===null&&xd(t.stateNode.containerInfo),zc(t),null;case 10:return Qi(t.type),zc(t),null;case 19:if(M(mo),r=t.memoizedState,r===null)return zc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Rc(r,!1);else{if(Hl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=ho(e),o!==null){for(t.flags|=128,Rc(r,!1),e=o.updateQueue,t.updateQueue=e,Lc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)P(n,e),n=n.sibling;return N(mo,mo.current&1|2),I&&Mi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ae()>$l&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304)}else{if(!a)if(e=ho(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Lc(t,e),Rc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!I)return zc(t),null}else 2*Ae()-r.renderingStartTime>$l&&n!==536870912&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(zc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ae(),e.sibling=null,n=mo.current,N(mo,a?n&1|2:n&1),I&&Mi(t,r.treeForkCount),e);case 22:case 23:return po(t),ao(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(zc(t),t.subtreeFlags&6&&(t.flags|=8192)):zc(t),n=t.updateQueue,n!==null&&Lc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&M(xa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Qi(ua),zc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Vc(e,t){switch(Fi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Qi(ua),he(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return _e(t),null;case 31:if(t.memoizedState!==null){if(po(t),t.alternate===null)throw Error(i(340));Gi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(po(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Gi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return M(mo),null;case 4:return he(),null;case 10:return Qi(t.type),null;case 22:case 23:return po(t),ao(),e!==null&&M(xa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Qi(ua),null;case 25:return null;default:return null}}function Hc(e,t){switch(Fi(t),t.tag){case 3:Qi(ua),he();break;case 26:case 27:case 5:_e(t);break;case 4:he();break;case 31:t.memoizedState!==null&&po(t);break;case 13:po(t);break;case 19:M(mo);break;case 10:Qi(t.type);break;case 22:case 23:po(t),ao(),e!==null&&M(xa);break;case 24:Qi(ua)}}function Uc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Uu(t,t.return,e)}}function Wc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Uu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Uu(t,t.return,e)}}function Gc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{eo(t,n)}catch(t){Uu(e,e.return,t)}}}function Kc(e,t,n){n.props=Xs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Uu(e,t,n)}}function qc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Uu(e,t,n)}}function Jc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Uu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Uu(e,t,n)}else n.current=null}function Yc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Uu(e,e.return,t)}}function Xc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ft]=t}catch(t){Uu(e,e.return,t)}}function Zc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Qc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Zc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function $c(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=an));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for($c(e,t,n),e=e.sibling;e!==null;)$c(e,t,n),e=e.sibling}function el(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(el(e,t,n),e=e.sibling;e!==null;)el(e,t,n),e=e.sibling}function tl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[dt]=e,t[ft]=n}catch(t){Uu(e,e.return,t)}}var nl=!1,rl=!1,U=!1,il=typeof WeakSet==`function`?WeakSet:Set,W=null;function al(e,t){if(e=e.containerInfo,Rd=sp,e=Mr(e),Nr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,W=t;W!==null;)if(t=W,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,W=e;else for(;W!==null;){switch(t=W,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[dt]=e,wt(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Ar(s,h),v=Ar(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,k.T=null,n=su,su=null;var o=ru,s=au;if(nu=0,iu=ru=null,au=0,Y&6)throw Error(i(331));var c=Y;if(Y|=4,jl(o.current),J(o,o.current,s,n),Y=c,rd(0,!1),Be&&typeof Be.onPostCommitFiberRoot==`function`)try{Be.onPostCommitFiberRoot(ze,o)}catch{}return!0}finally{A.p=a,k.T=r,zu(e,t)}}function Hu(e,t,n){t=Si(n,t),t=tc(e.stateNode,t,2),e=qa(e,t,2),e!==null&&(tt(e,2),nd(e))}function Uu(e,t,n){if(e.tag===3)Hu(e,e,n);else for(;t!==null;){if(t.tag===3){Hu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(tu===null||!tu.has(r))){e=Si(n,e),n=nc(2),r=qa(t,n,2),r!==null&&(rc(n,r,t,e),tt(r,2),nd(r));break}}t=t.return}}function Wu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Fl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Bl=!0,i.add(n),e=Gu.bind(null,e,t,n),t.then(e,e))}function Gu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Il===e&&(Z&n)===n&&(Hl===4||Hl===3&&(Z&62914560)===Z&&300>Ae()-Zl?!(Y&2)&&bu(e,0):Gl|=n,ql===Z&&(ql=0)),nd(e)}function Ku(e,t){t===0&&(t=$e()),e=ci(e,t),e!==null&&(tt(e,t),nd(e))}function qu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ku(e,n)}function Ju(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Ku(e,n)}function Yu(e,t){return Ee(e,t)}var Xu=null,Zu=null,Qu=!1,$u=!1,ed=!1,td=0;function nd(e){e!==Zu&&e.next===null&&(Zu===null?Xu=Zu=e:Zu=Zu.next=e),$u=!0,Qu||(Qu=!0,ld())}function rd(e,t){if(!ed&&$u){ed=!0;do for(var n=!1,r=Xu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-He(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,cd(r,a))}else a=Z,a=Xe(r,r===Il?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ze(r,a)||(n=!0,cd(r,a));r=r.next}while(n);ed=!1}}function id(){ad()}function ad(){$u=Qu=!1;var e=0;td!==0&&Gd()&&(e=td);for(var t=Ae(),n=null,r=Xu;r!==null;){var i=r.next,a=od(r,t);a===0?(r.next=null,n===null?Xu=i:n.next=i,i===null&&(Zu=n)):(n=r,(e!==0||a&3)&&($u=!0)),r=i}nu!==0&&nu!==5||rd(e,!1),td!==0&&(td=0)}function od(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Ut(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),wt(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Ut(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Ut(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Ut(n.imageSizes)+`"]`)):i+=`[href="`+Ut(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=m({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),wt(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Ut(r)+`"][href="`+Ut(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=m({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),wt(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Ct(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=m({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);wt(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=Ct(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),wt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=Ct(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),wt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=fe.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=Ct(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=Ct(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=Ct(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Ut(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return m({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),wt(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Ut(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Ut(n.href)+`"]`);if(r)return t.instance=r,wt(r),r;var a=m({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),wt(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,wt(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),wt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,wt(a),a):(r=n,(a=mf.get(o))&&(r=m({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),wt(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,wt(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),wt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=g()})),v=`modulepreload`,y=function(e){return`/admin/`+e},b={},x=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,new URL(`../../../src/node/plugins/importAnalysisBuild.ts`,import.meta.url)).href}r=o(t.map(t=>{if(t=y(t,n),t=s(t),t in b)return;b[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:v,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},S=l(d(),1),C=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,w=/^[\\/]{2}/;function T(e,t){return t+e.replace(/\\/g,`/`)}var ee=`popstate`;function te(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function ne(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return ie(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:ae(t)}return k(t,n,null,e)}function E(e,t){if(e===!1||e==null)throw Error(t)}function D(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function O(){return Math.random().toString(36).substring(2,10)}function re(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function ie(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?oe(t):t,state:n,key:t&&t.key||r||O(),mask:i}}function ae({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function oe(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function k(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=te(e)?e:ie(h.location,e,t);n&&n(r,e),l=u()+1;let d=re(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=te(e)?e:ie(h.location,e,t);n&&n(r,e),l=u();let i=re(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return A(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(ee,d),c=e,()=>{i.removeEventListener(ee,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function A(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),E(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:ae(t);return i=i.replace(/ $/,`%20`),!n&&w.test(i)&&(i=r+i),new URL(i,r)}function se(e,t,n=`/`){return ce(e,t,n,!1)}function ce(e,t,n,r,i){let a=Te((typeof t==`string`?oe(t):t).pathname||`/`,n);if(a==null)return null;let o=i??j(e),s=null,c=we(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;E(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=Ne([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(E(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),M(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:ve(l,e.index),routesMeta:u.map((e,t)=>{let[n,r]=Ce(e.relativePath,e.caseSensitive,t===u.length-1);return{...e,matcher:n,compiledParams:r}})})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of N(e.path))a(e,t,!0,n)}),t}function N(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=N(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function ue(e){e.sort((e,t)=>e.score===t.score?ye(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var de=/^:[\w-]+$/,fe=3,pe=2,me=1,he=10,ge=-2,_e=e=>e===`*`;function ve(e,t){let n=e.split(`/`),r=n.length;return n.some(_e)&&(r+=ge),t&&(r+=pe),n.filter(e=>!_e(e)).reduce((e,t)=>e+(de.test(t)?fe:t===``?me:he),r)}function ye(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function be(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function Ce(e,t=!1,n=!0){D(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function we(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return D(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function Te(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}function Ee(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?oe(e):e,a;return n?(n=Me(n),a=n.startsWith(`/`)?De(n.substring(1),`/`):De(n,t)):a=t,{pathname:a,search:Ie(r),hash:Le(i)}}function De(e,t){let n=Pe(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function Oe(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function ke(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Ae(e){let t=ke(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function je(e,t,n,r=!1){let i;typeof e==`string`?i=oe(e):(i={...e},E(!i.pathname||!i.pathname.includes(`?`),Oe(`?`,`pathname`,`search`,i)),E(!i.pathname||!i.pathname.includes(`#`),Oe(`#`,`pathname`,`hash`,i)),E(!i.search||!i.search.includes(`#`),Oe(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=Ee(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var Me=e=>e.replace(/[\\/]{2,}/g,`/`),Ne=e=>Me(e.join(`/`)),Pe=e=>e.replace(/\/+$/,``),Fe=e=>Pe(e).replace(/^\/*/,`/`),Ie=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,Le=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Re=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function ze(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Be(e){return Ne(e.map(e=>e.route.path).filter(Boolean))||`/`}var Ve=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function He(e,t){let n=e;if(typeof n!=`string`||!C.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(Ve)try{let e=new URL(window.location.href),r=w.test(n)?new URL(T(n,e.protocol)):new URL(n),a=Te(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{D(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Ue=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Ue);var We=[`GET`,...Ue];new Set(We);var Ge=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];function Ke(e){try{return Ge.includes(new URL(e).protocol)}catch{return!1}}var qe=S.createContext(null);qe.displayName=`DataRouter`;var Je=S.createContext(null);Je.displayName=`DataRouterState`;var Ye=S.createContext(!1);function Xe(){return S.useContext(Ye)}var Ze=S.createContext({isTransitioning:!1});Ze.displayName=`ViewTransition`;var Qe=S.createContext(new Map);Qe.displayName=`Fetchers`;var $e=S.createContext(null);$e.displayName=`Await`;var et=S.createContext(null);et.displayName=`Navigation`;var tt=S.createContext(null);tt.displayName=`Location`;var nt=S.createContext({outlet:null,matches:[],isDataRoute:!1});nt.displayName=`Route`;var rt=S.createContext(null);rt.displayName=`RouteError`;var it=`REACT_ROUTER_ERROR`,at=`REDIRECT`,ot=`ROUTE_ERROR_RESPONSE`;function st(e){if(e.startsWith(`${it}:${at}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function ct(e){if(e.startsWith(`${it}:${ot}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new Re(t.status,t.statusText,t.data)}catch{}}function lt(e,{relative:t}={}){E(ut(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=S.useContext(et),{hash:i,pathname:a,search:o}=vt(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:Ne([n,a])),r.createHref({pathname:s,search:o,hash:i})}function ut(){return S.useContext(tt)!=null}function dt(){return E(ut(),`useLocation() may be used only in the context of a component.`),S.useContext(tt).location}var ft=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function pt(e){S.useContext(et).static||S.useLayoutEffect(e)}function mt(){let{isDataRoute:e}=S.useContext(nt);return e?Lt():ht()}function ht(){E(ut(),`useNavigate() may be used only in the context of a component.`);let e=S.useContext(qe),{basename:t,navigator:n}=S.useContext(et),{matches:r}=S.useContext(nt),{pathname:i}=dt(),a=JSON.stringify(Ae(r)),o=S.useRef(!1);return pt(()=>{o.current=!0}),S.useCallback((r,s={})=>{if(D(o.current,ft),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=je(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Ne([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var gt=S.createContext(null);function _t(e){let t=S.useContext(nt).outlet;return S.useMemo(()=>t&&S.createElement(gt.Provider,{value:e},t),[t,e])}function vt(e,{relative:t}={}){let{matches:n}=S.useContext(nt),{pathname:r}=dt(),i=JSON.stringify(Ae(n));return S.useMemo(()=>je(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function yt(e,t){return bt(e,t)}function bt(e,t,n){E(ut(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=S.useContext(et),{matches:i}=S.useContext(nt),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;zt(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let u=dt(),d;if(t){let e=typeof t==`string`?oe(t):t;E(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):se(e,{pathname:p});D(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),D(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=Dt(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:Ne([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Ne([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?S.createElement(tt.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function xt(){let e=It(),t=ze(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=S.createElement(S.Fragment,null,S.createElement(`p`,null,`💿 Hey developer 👋`),S.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,S.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,S.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),S.createElement(S.Fragment,null,S.createElement(`h2`,null,`Unexpected Application Error!`),S.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?S.createElement(`pre`,{style:i},n):null,o)}var St=S.createElement(xt,null),Ct=class extends S.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=ct(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:S.createElement(nt.Provider,{value:this.props.routeContext},S.createElement(rt.Provider,{value:e,children:this.props.component}));return this.context?S.createElement(Tt,{error:e},t):t}};Ct.contextType=Ye;var wt=new WeakMap;function Tt({children:e,error:t}){let{basename:n}=S.useContext(et);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=st(t.digest);if(e){let r=wt.get(t);if(r)throw r;let i=He(e.location,n),a=i.absoluteURL||i.to;if(Ke(a))throw Error(`Invalid redirect location`);if(Ve&&!wt.get(t))if(i.isExternal||e.reloadDocument)window.location.href=a;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw wt.set(t,n),n}return S.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${a}`})}}return e}function Et({routeContext:e,match:t,children:n}){let r=S.useContext(qe);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),S.createElement(nt.Provider,{value:e},n)}function Dt(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);E(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Be(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||St,o&&(s<0&&c===0?(zt(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?S.createElement(n.route.Component,null):n.route.element?n.route.element:e,S.createElement(Et,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?S.createElement(Ct,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function Ot(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function kt(e){let t=S.useContext(qe);return E(t,Ot(e)),t}function At(e){let t=S.useContext(Je);return E(t,Ot(e)),t}function jt(e){let t=S.useContext(nt);return E(t,Ot(e)),t}function Mt(e){let t=jt(e),n=t.matches[t.matches.length-1];return E(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Nt(){return Mt(`useRouteId`)}function Pt(){let e=At(`useNavigation`);return S.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function Ft(){let{matches:e,loaderData:t}=At(`useMatches`);return S.useMemo(()=>e.map(e=>le(e,t)),[e,t])}function It(){let e=S.useContext(rt),t=At(`useRouteError`),n=Mt(`useRouteError`);return e===void 0?t.errors?.[n]:e}function Lt(){let{router:e}=kt(`useNavigate`),t=Mt(`useNavigate`),n=S.useRef(!1);return pt(()=>{n.current=!0}),S.useCallback(async(r,i={})=>{D(n.current,ft),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Rt={};function zt(e,t,n){!t&&!Rt[e]&&(Rt[e]=!0,D(!1,n))}S.memo(Bt);function Bt({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return bt(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Vt({to:e,replace:t,state:n,relative:r}){E(ut(),` may be used only in the context of a component.`);let{static:i}=S.useContext(et);D(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=S.useContext(nt),{pathname:o}=dt(),s=mt(),c=je(e,Ae(a),o,r===`path`),l=JSON.stringify(c);return S.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function Ht(e){return _t(e.context)}function Ut(e){E(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Wt({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){E(!ut(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=S.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=oe(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=S.useMemo(()=>{let e=Te(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return D(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:S.createElement(et.Provider,{value:c},S.createElement(tt.Provider,{children:t,value:h}))}function Gt({children:e,location:t}){return yt(Kt(e),t)}S.Component;function Kt(e,t=[]){let n=[];return S.Children.forEach(e,(e,r)=>{if(!S.isValidElement(e))return;let i=[...t,r];if(e.type===S.Fragment){n.push.apply(n,Kt(e.props.children,i));return}E(e.type===Ut,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),E(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Kt(e.props.children,i)),n.push(a)}),n}var qt=`get`,Jt=`application/x-www-form-urlencoded`;function Yt(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Xt(e){return Yt(e)&&e.tagName.toLowerCase()===`button`}function Zt(e){return Yt(e)&&e.tagName.toLowerCase()===`form`}function Qt(e){return Yt(e)&&e.tagName.toLowerCase()===`input`}function $t(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function en(e,t){return e.button===0&&(!t||t===`_self`)&&!$t(e)}var tn=null;function nn(){if(tn===null)try{new FormData(document.createElement(`form`),0),tn=!1}catch{tn=!0}return tn}var rn=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function an(e){return e!=null&&!rn.has(e)?(D(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Jt}"`),null):e}function on(e,t){let n,r,i,a,o;if(Zt(e)){let o=e.getAttribute(`action`);r=o?Te(o,t):null,n=e.getAttribute(`method`)||qt,i=an(e.getAttribute(`enctype`))||Jt,a=new FormData(e)}else if(Xt(e)||Qt(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a + + +
+ {SPECIES.map((s) => ( + + ))} + + 共 {list.length} 种,{letters.length} 个首字母 + +
+ + + + + + + 首字母 + 品种 + 排序 + 状态 + 操作 + + + + {letters.map((L) => + groups[L].map((b, i) => ( + + + {i === 0 ? L : ''} + + {b.name} + {b.sort} + + {b.enabled ? ( + 启用 + ) : ( + 停用 + )} + + + + + + + )), + )} + {!list.length && ( + + + 还没有品种 + + + )} + +
+
+
+ + + + + {form.id ? '编辑品种' : '新增品种'} + +
+
+ +
+ {SPECIES.map((s) => ( + + ))} +
+
+
+ + +
+
+ + setForm({ ...form, initial: e.target.value.toUpperCase() })} + placeholder="B" + className="w-20 font-mono" + /> +

+ 按国内常用读法填。多音字自己定:藏獒填 Z,柴犬填 C。 +

+
+
+ + setForm({ ...form, sort: Number(e.target.value) || 0 })} + className="w-28" + /> +
+
+ setForm({ ...form, enabled: e.target.checked })} + /> + +
+ {err &&

{err}

} + +
+
+
+ + ) +} diff --git a/pets-be/web/admin/src/pages/CareTemplates.tsx b/pets-be/web/admin/src/pages/CareTemplates.tsx index 477a5d4..91ea8e4 100644 --- a/pets-be/web/admin/src/pages/CareTemplates.tsx +++ b/pets-be/web/admin/src/pages/CareTemplates.tsx @@ -1,285 +1,523 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Cat, Dog, Plus, Trash2, Wand2, Info } from 'lucide-react' import { api } from '@/lib/api' -import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' const SPECIES = [ { key: 'cat', label: '猫猫', Icon: Cat }, { key: 'dog', label: '狗狗', Icon: Dog }, ] -const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期'] - -const SHEET_LABEL: Record = { - '': '无', weight: '体重', poop: '便便', food: '饮食', symptom: '异常', - vaccine: '疫苗', medicine: '用药', report: '报告', taskDetail: '任务详情', -} +// 阶段列表从后端 meta 来(service.CareStages)。写死在这里的话, +// 后端从 3 段细分成 5 段之后这一页就少两个选项,而且不会报错 const REMINDER_LABEL: Record = { vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报', } -const TASK_SHEETS = ['', 'weight', 'poop', 'food', 'symptom', 'vaccine', 'medicine'] -const PLAN_SHEETS = ['', 'weight', 'vaccine', 'medicine', 'report', 'taskDetail'] const REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport'] +const REPEATS = [ + { key: 'daily', label: '每天' }, + { key: 'weekly', label: '每周' }, + { key: 'monthly', label: '每月' }, + { key: 'once', label: '一次性' }, +] +const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'] +// 阶段的展示顺序。后端 CareStages 就是这个顺序,但列表里要用它排序, +// 单独列一份免得依赖接口返回的时序 +const STAGE_ORDER = ['刚到家 0-30 天', '幼年期', '青年期', '成年期', '中年期', '老年期'] -const selectCls = - 'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0' +const selectCls = 'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0' type Item = { _k: number - kind: 'task' | 'plan' | 'reminder' + kind: 'plan' | 'reminder' title: string description: string priority: string sheet_type: string + repeat: string day: number - day_label: string + weekdays: string + month_day: number reminder_type: string offset_days: number frequency: string } +type Tpl = { + id: string + species: string + stage: string + name: string + summary: string + sort: number + enabled: boolean + items?: any[] +} let uid = 1 -function normalize(raw: any[]): Item[] { - return (raw || []).map((r) => ({ +function toItem(r: any): Item { + return { _k: uid++, - kind: r.kind, + kind: r.kind === 'reminder' ? 'reminder' : 'plan', title: r.title || '', description: r.description || '', priority: r.priority || '', sheet_type: r.sheet_type || '', + repeat: r.repeat || 'once', day: r.day ?? 0, - day_label: r.day_label || '', + weekdays: r.weekdays || '', + month_day: r.month_day ?? 1, reminder_type: r.reminder_type || 'vaccine', offset_days: r.offset_days ?? 0, frequency: r.frequency || '', - })) + } } function blank(kind: Item['kind']): Item { - return { - _k: uid++, kind, title: '', description: '', priority: '', sheet_type: '', - day: 0, day_label: '', reminder_type: 'vaccine', offset_days: 0, frequency: '', - } + return toItem({ kind, repeat: kind === 'plan' ? 'daily' : '' }) } export default function CareTemplates() { - const [species, setSpecies] = useState('cat') - const [stage, setStage] = useState('成年期') + const [list, setList] = useState([]) + const [tab, setTab] = useState('cat') + const [types, setTypes] = useState([]) + const [stages, setStages] = useState([]) + const [open, setOpen] = useState(false) + const [tpl, setTpl] = useState({ + id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true, + }) const [items, setItems] = useState([]) - const [loading, setLoading] = useState(false) + const [err, setErr] = useState('') const [saving, setSaving] = useState(false) const [gen, setGen] = useState(false) - const [dirty, setDirty] = useState(false) - const [msg, setMsg] = useState('') - const reqId = useRef(0) - async function load(sp: string, st: string) { - const my = ++reqId.current - setLoading(true) - setMsg('') + function reload() { + api.careTemplates().then((r) => setList(r || [])).catch(() => setList([])) + } + useEffect(() => { + reload() + // 「关联记录事项」的可选值来自 record_types,不再是写死的几个。 + // 原来那两个硬编码数组里还有 report / taskDetail 这种早就不存在的值 + api + .careTemplateMeta() + .then((m: any) => { + setTypes(m?.record_types || []) + setStages(m?.stages || []) + }) + .catch(() => {}) + }, []) + + function create() { + // 默认跟当前 tab 的物种,省一步点击 + setTpl({ id: '', species: tab, stage: '成年期', name: '', summary: '', sort: 0, enabled: true }) + setItems([]) + setErr('') + setOpen(true) + } + async function edit(t: Tpl) { + setErr('') + const full = await api.careTemplate(t.id) + setTpl({ ...t }) + setItems(((full as any)?.items || []).map(toItem)) + setOpen(true) + } + async function save() { + setErr('') + setSaving(true) try { - const data = await api.careTemplate(sp, st) - if (my === reqId.current) { - setItems(normalize(data.items)) - setDirty(false) - } + await api.saveCareTemplate({ ...tpl, items }) + setOpen(false) + reload() + } catch (e: any) { + // 后端会指名道姓说哪条配错了(「XX 选了每周,但没勾星期几」) + setErr(e?.message || '保存失败') } finally { - if (my === reqId.current) setLoading(false) + setSaving(false) } } - - useEffect(() => { - load(species, stage) - }, [species, stage]) - - function update(k: number, key: keyof Item, value: any) { - setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [key]: value } : it))) - setDirty(true) + async function del(t: Tpl) { + if (!confirm(`删除方案「${t.name || t.stage}」?已经套用出去的计划不受影响。`)) return + await api.deleteCareTemplate(t.id).catch((e: any) => alert(e?.message || '删除失败')) + reload() } - function remove(k: number) { - setItems((prev) => prev.filter((it) => it._k !== k)) - setDirty(true) - } - function add(kind: Item['kind']) { - setItems((prev) => [...prev, blank(kind)]) - setDirty(true) - } - - async function generate() { + async function draft() { setGen(true) - setMsg('') try { - const data = await api.genCareTemplate(species, stage) - setItems(normalize(data.items)) - setDirty(true) - setMsg('已生成草稿,确认无误后点「保存该模板」') + const r: any = await api.genCareTemplate(tpl.species, tpl.stage) + setItems((r?.items || []).map(toItem)) } catch (e: any) { - setMsg(e?.message || 'AI 生成失败') + setErr(e?.message || 'AI 生成失败') } finally { setGen(false) } } - async function save() { - setSaving(true) - setMsg('') - try { - await api.saveCareTemplate(species, stage, items) - setDirty(false) - setMsg('已保存') - } catch (e: any) { - setMsg(e?.message || '保存失败') - } finally { - setSaving(false) - } + const upd = (k: number, field: string, v: any) => + setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [field]: v } : it))) + const rm = (k: number) => setItems((prev) => prev.filter((it) => it._k !== k)) + function toggleWd(it: Item, idx: number) { + const n = String(idx + 1) + const cur = it.weekdays.split(',').filter(Boolean) + const next = cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n].sort() + upd(it._k, 'weekdays', next.join(',')) } - const tasks = useMemo(() => items.filter((i) => i.kind === 'task'), [items]) - const plans = useMemo(() => items.filter((i) => i.kind === 'plan'), [items]) - const reminders = useMemo(() => items.filter((i) => i.kind === 'reminder'), [items]) + // 按 tab 过滤,并按阶段顺序排(数据库按 sort 排,但 sort 是全局的, + // 分物种看时会跳号) + const order = STAGE_ORDER + const shown = list + .filter((t) => t.species === tab) + .sort((a, b) => order.indexOf(a.stage) - order.indexOf(b.stage)) + + const plans = items.filter((i) => i.kind === 'plan') + const reminders = items.filter((i) => i.kind === 'reminder') + + // 列表里展示「这套方案套一个月大概会排多少条」——只报明细条数会骗人: + // 4 条「每天」套一整月就是 120 多条 + function spread(t: Tpl) { + const its = t.items || [] + const c = { daily: 0, weekly: 0, monthly: 0, once: 0, rem: 0 } + its.forEach((i: any) => { + if (i.kind === 'reminder') c.rem++ + else if (i.repeat === 'daily') c.daily++ + else if (i.repeat === 'weekly') c.weekly++ + else if (i.repeat === 'monthly') c.monthly++ + else c.once++ + }) + return c + } return (
-
+
-

养护模板

-

- 建档时按宠物的物种和阶段套用;改动只影响之后建档 / 重新建档的宠物。 +

养护方案

+

+ 用户建档最后一步从这里挑一套,也可以选「先空着自己排」。同一个物种阶段可以配多套。

-
- - -
+
-
- {SPECIES.map((s) => ( - - ))} -
-
- {STAGES.map((st) => ( - - ))} + {/* 猫狗分开看。混在一起 10 套要上下找,而且阶段名一样(幼年期/青年期…), + 扫一眼分不清哪套是猫的 */} +
+ {SPECIES.map((s) => { + const n = list.filter((t) => t.species === s.key).length + return ( + + ) + })}
- {msg && ( -
- {msg} -
- )} + + + + + + 阶段 + 方案名 + 内容 + 状态 + 操作 + + + + {shown.map((t) => { + const c = spread(t) + return ( + + {t.stage} + +
{t.name || '(未命名)'}
+
{t.summary}
+
+ + 每天 {c.daily} · 每周 {c.weekly} · 每月 {c.monthly} · 一次性 {c.once} · 提醒 {c.rem} + + + {t.enabled ? 启用 : 停用} + + + + + +
+ ) + })} + {!shown.length && ( + + + 这个物种还没有方案 —— 用户建档后计划页会是空的,只能自己排 + + + )} +
+
+
+
- {loading ? ( -

加载中…

- ) : ( -
-
add('task')}> - {tasks.map((it) => ( -
- update(it._k, 'title', e.target.value)} /> - update(it._k, 'description', e.target.value)} /> - - - remove(it._k)} /> -
- ))} -
+ + + + {tpl.id ? '编辑方案' : '新建方案'} + -
add('plan')}> - {plans.map((it) => ( -
- update(it._k, 'day', Number(e.target.value))} /> - update(it._k, 'day_label', e.target.value)} /> - update(it._k, 'title', e.target.value)} /> - update(it._k, 'description', e.target.value)} /> - - remove(it._k)} /> -
- ))} -
- -
add('reminder')}> - {reminders.map((it) => ( -
- - update(it._k, 'title', e.target.value)} /> -
- 到期+ - update(it._k, 'offset_days', Number(e.target.value))} /> - +
+
+
+ +
+ {SPECIES.map((s) => ( + + ))}
- update(it._k, 'frequency', e.target.value)} /> - remove(it._k)} />
- ))} -
+
+ + +
+
+
+ + setTpl({ ...tpl, name: e.target.value })} + placeholder="幼猫标准照护" + /> +
+
+ + setTpl({ ...tpl, summary: e.target.value })} + placeholder="每天清砂盆和喂食,每周称体重,含疫苗驱虫提醒" + /> +
+
+ setTpl({ ...tpl, enabled: e.target.checked })} + /> + +
-

- 模板留空时建档会回退内置默认,不会让新宠没有任务。 -

-
- )} + {/* ── 计划事项 ── */} +
+
+
计划事项({plans.length})
+
+ + +
+
+
+ + + 「关联记录事项」= 用户在小程序点这条计划时,直接跳到哪个记录表单。 + 比如「记录一次体重」关联体重,点了就是体重表单,不用自己去记录页找。 + 这个下拉读的是记录事项那一页的配置。 + +
+
+ {plans.map((it) => ( +
+ upd(it._k, 'title', e.target.value)} + /> + upd(it._k, 'description', e.target.value)} + /> + + {it.repeat === 'weekly' && ( +
+ {WEEKDAYS.map((w, i) => ( + + ))} +
+ )} + {it.repeat === 'monthly' && ( + upd(it._k, 'month_day', Number(e.target.value) || 1)} + /> + )} + {it.repeat === 'once' && ( + upd(it._k, 'day', Number(e.target.value) || 0)} + /> + )} + + + +
+ ))} + {!plans.length && ( +

还没有计划事项

+ )} +
+
+ + {/* ── 提醒 ── */} +
+
+
到期提醒({reminders.length})
+ +
+

+ 提醒不占计划页的格子,只在到期时冒出来。疫苗和驱虫这两类建档时就会自动建 + (漏针是真会出事的),不用等用户挑方案。 +

+
+ {reminders.map((it) => ( +
+ + upd(it._k, 'title', e.target.value)} + /> + upd(it._k, 'offset_days', Number(e.target.value) || 0)} + /> + upd(it._k, 'frequency', e.target.value)} + /> + +
+ ))} + {!reminders.length && ( +

还没有提醒

+ )} +
+
+ + {err &&

{err}

} + +
+ +
) } - -function Section({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) { - return ( - - -
-
- {title} {count} -
- -
-
- {count === 0 ?

暂无,点「添加」新增一条。

: children} -
-
-
- ) -} - -function DelBtn({ onClick }: { onClick: () => void }) { - return ( - - ) -} diff --git a/pets-be/web/admin/src/pages/CommunityBot.tsx b/pets-be/web/admin/src/pages/CommunityBot.tsx index 2e0917c..68f326d 100644 --- a/pets-be/web/admin/src/pages/CommunityBot.tsx +++ b/pets-be/web/admin/src/pages/CommunityBot.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' type Cfg = { enabled: boolean; daily_count: number; start_hour: number; end_hour: number } +type Quota = { enabled: boolean; daily_chat: number; daily_symptom: number; daily_plan: number } export default function CommunityBot() { const [cfg, setCfg] = useState({ enabled: true, daily_count: 3, start_hour: 9, end_hour: 21 }) @@ -15,14 +16,38 @@ export default function CommunityBot() { const [genN, setGenN] = useState(5) const [gen, setGen] = useState(false) const [msg, setMsg] = useState('') + // AI 额度和社区机器人都是「烧多少 token」的开关,放一页里管 + const [q, setQ] = useState({ enabled: true, daily_chat: 20, daily_symptom: 10, daily_plan: 5 }) + const [qSaving, setQSaving] = useState(false) + const [qMsg, setQMsg] = useState('') useEffect(() => { api.communityBot().then((c) => { setCfg(c) setLoading(false) }) + api.aiQuota().then(setQ).catch(() => {}) }, []) + const qNum = (k: keyof Quota) => (e: any) => { + let v = Number(e.target.value) + if (Number.isNaN(v) || v < 0) v = 0 + setQ({ ...q, [k]: Math.min(999, v) }) + } + + async function saveQuota() { + setQSaving(true) + setQMsg('') + try { + setQ(await api.saveAIQuota(q)) + setQMsg('已保存,立即生效') + } catch (e: any) { + setQMsg(e.message || '保存失败') + } finally { + setQSaving(false) + } + } + const num = (k: keyof Cfg, min: number, max: number) => (e: any) => { let v = Number(e.target.value) if (Number.isNaN(v)) v = min @@ -144,6 +169,51 @@ export default function CommunityBot() { )} - + + + +
+ + AI 每日次数上限 +
+

+ + + 按「每个用户 / 每个自然日」计,改完立即生效。填 0 表示该功能不限次。 + 总开关关掉则全部不限——AI 调用是真金白银,关之前想清楚。 + +

+
+ setQ({ ...q, enabled: e.target.checked })} + /> + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {qMsg && {qMsg}} +
+
+
+ ) } diff --git a/pets-be/web/admin/src/pages/Login.tsx b/pets-be/web/admin/src/pages/Login.tsx index 8355b27..0ef5898 100644 --- a/pets-be/web/admin/src/pages/Login.tsx +++ b/pets-be/web/admin/src/pages/Login.tsx @@ -7,19 +7,24 @@ import { Label } from '@/components/ui/label' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' export default function Login() { - const [username, setUsername] = useState('sundynix') - const [password, setPassword] = useState('sundynix') + // 不预填账号密码:预填等于把后台凭证写在页面上 + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) const navigate = useNavigate() async function submit(e: React.FormEvent) { e.preventDefault() + if (!username.trim() || !password) { + setError('请输入账号和密码') + return + } setLoading(true) setError('') try { - const res = await api.login(username, password) - setToken(res.token) + const res = await api.login(username.trim(), password) + setToken(res.token, res.refresh_token) navigate('/') } catch (err: any) { setError(err.message || '登录失败') @@ -39,11 +44,22 @@ export default function Login() {
- setUsername(e.target.value)} /> + setUsername(e.target.value)} + />
- setPassword(e.target.value)} /> + setPassword(e.target.value)} + />
{error &&

{error}

} + + + + + + + + 分组 + 名称 + code + 表单字段 + 排序 + 状态 + 操作 + + + + {GROUPS.map((g) => + (byGroup[g.key] || []).map((t, i) => ( + + {i === 0 ? g.label : ''} + {t.label} + {t.code} + {fieldsText(t)} + {t.sort} + + {t.enabled ? 启用 : 停用} + {t.locked && ( + + 内置 + + )} + + + + {!t.locked && ( + + )} + + + )), + )} + {!list.length && ( + + + 还没有事项 —— 小程序的记录页会是空的 + + + )} + +
+
+
+ + + + + {form.id ? `编辑「${form.label}」` : '新增事项'} + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ {GROUPS.map((g) => ( + + ))} +
+
+
+ + +
+
+
+ + - - + + + 共 {{total}} 条评论 - - - 记录便便 - 选择状态并补充备注,几秒完成。 - - 正常 - 软便 - 拉稀 - - - - - + + + {{item.initial}} + + {{item.author_name}} + {{item.content}} + + + + + {{item.timeText}} + 回复 + 删除 + - - - 记录饮食 - - - - 正常 - 偏少 - 不吃 - - - + + + + {{r.initial}} + + + {{r.author_name}} + 回复 {{r.reply_to_name}} + + {{r.content}} + + {{r.timeText}} + 回复 + 删除 + + + + + — 展开全部 {{item.reply_n}} 条回复 + + + + - - - 异常观察 - 如实描述异常,AI 会给出观察建议与就医提示(非诊断)。 - - 呕吐 - 拉稀 - 精神差 - 皮肤红 - - - 刚刚 - 半天 - 1天以上 - - - 正常 - 一般 - 明显变差 - - - + + + 还没有评论 + 来抢个沙发,说点什么吧~ + + - - - AI 观察建议 - ⚠️ 风险等级:{{riskData ? riskData.risk_level : '中'}} - 可能原因:{{riskData ? riskData.causes : '可能与换粮、应激或消化不适有关。不要把这里写成诊断结论。'}} - 建议:{{riskData ? riskData.suggestion : '继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。'}} - 建议就医:{{riskData ? riskData.seek_care : '如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。'}} - - - + + + {{commentText || '说点什么…'}} + + + + + + + {{replyTo.name ? '回复 ' + replyTo.name : '写评论'}} + + + + + + + + + + + + {{commentText.length}}/500 + 发送 + - - + + + + + 就医前摘要 宠物:{{pet.name}} 阶段:{{pet.stage}} 当前体重:{{pet.weight}} 近期重点:疫苗期、换粮观察、排便记录 建议携带:排便/呕吐照片、饮食变化、疫苗驱虫记录。 - + - - 记录用药 - - - - - - - - 记一笔消费 - - - 食品 - 医疗 - 用品 - - - - - - - - 疫苗提醒 - 为 {{pet.name}} 设置疫苗提醒。接种前后避免洗澡和长途出行。 - - - {{dateVals.vaccine}} - - - - - - - 驱虫提醒 - 设置下次驱虫日期,到点提醒你。 - - - {{dateVals.deworm}} - - - - - - - 成长照片 - 上传成长照片,用于宠物档案与成长报告。 - - {{pet.emoji}} - {{pet.name}} 的成长照片 - 模拟上传预览 - - - - 提醒中心 @@ -184,24 +150,6 @@ module.exports.sel = function (map, key, index, def) { - - - 问问 AI - - {{item.text}} - - - - 猫咪吐了一次 - 多久驱虫 - 换粮软便 - - - - - - @@ -216,7 +164,14 @@ module.exports.sel = function (map, key, index, def) { 健康状态:{{poster ? poster.headline : '稳定成长'}} 生成自:肉垫计划 - + + + + + + @@ -232,9 +187,9 @@ module.exports.sel = function (map, key, index, def) { 肉垫计划 Pro 解锁 365 天计划、PDF 健康档案、月度报告、多宠物管理与年度账单。 - {{item}} + {{item}} - + 会员功能还在准备中,暂未开放购买。 @@ -242,36 +197,8 @@ module.exports.sel = function (map, key, index, def) { - - {{innerType === 'editPet' ? '编辑档案' : '添加毛孩子'}} - - - 猫猫 - 狗狗 - - - 男孩 - 女孩 - 不确定 - - - - {{addBirthday || '选择日期'}} - - - - - - 刚到家 - 幼年期 - 成年期 - 老年期 - - - - - - + 发布图文 选择发布身份,分享你的养宠日常。 @@ -292,58 +219,16 @@ module.exports.sel = function (map, key, index, def) { - × + - + - - - 管理任务 - 增删改今日任务,改动会自动顺延到以后每天。 - - - {{item.title}}{{item.priority}} - {{item.description}} - - - - - 今天还没有任务,下面加一条吧。 - - - - - - - 重要 - - 关联:{{taskSheetLabels[taskForm.sheetIdx]}} - - - - - - - - - 评论 - - {{item.author_name}} - {{item.content}} - - 还没有评论,来抢沙发~ - 长按自己的评论可删除 - - - - - - - + 多宠物管理 点一只切换为当前宠物,或编辑它的档案。 diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxss b/pets-fe/components/bottom-sheet/bottom-sheet.wxss index 2501820..547e849 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxss +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxss @@ -7,58 +7,164 @@ .overlay.show{opacity:1;pointer-events:auto} .sheet{ position:absolute;left:0;right:0;bottom:0; - background:#fff;border-radius:60rpx 60rpx 0 0; - padding:32rpx 0 0; + background:#fff;border-radius:48rpx 48rpx 0 0; + padding:var(--sp-5) 0 0; transform:translateY(106%);transition:.24s ease; - box-shadow:0 -40rpx 100rpx rgba(0,0,0,.16); + box-shadow:var(--sd-3); } .overlay.show .sheet{transform:translateY(0)} .sheet-scroll{ box-sizing:border-box; - max-height:78vh; - padding:0 36rpx calc(120rpx + env(safe-area-inset-bottom)); + /* exportData / manageTasks / comments 是最长的三型,圆角间距变化后靠这个上限内滚 */ + max-height:80vh; + padding:0 var(--sp-5) calc(120rpx + env(safe-area-inset-bottom)); } .sheet-scroll::-webkit-scrollbar,.plan-chat::-webkit-scrollbar{width:0;height:0;display:none} -.sheetbar{width:88rpx;height:10rpx;border-radius:999rpx;background:#E1D8CF;margin:0 auto 24rpx} +.sheetbar{width:80rpx;height:8rpx;border-radius:var(--r-full);background:#E1D8CF;margin:0 auto var(--sp-4)} -.sheet-h3{font-size:40rpx;font-weight:800;margin-bottom:20rpx} -.sheet-p{font-size:28rpx;color:#62584F;line-height:1.65;margin-bottom:24rpx} +.sheet-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)} +.sheet-p{font-size:var(--fs-md);color:var(--text-2);line-height:1.65;margin-bottom:var(--sp-4)} .sheet-p.pre,.poster-list.pre{white-space:pre-line} -.poster-h3{font-size:40rpx;font-weight:800;margin-bottom:20rpx} -.bold{font-weight:900} +.poster-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)} -.ai-input-row{display:flex;gap:16rpx;align-items:center} +.ai-input-row{display:flex;gap:var(--sp-3);align-items:center} .ai-input-row .input{flex:1} -.ai-send{height:88rpx;line-height:88rpx;padding:0 28rpx} +.ai-send{height:88rpx;line-height:88rpx;padding:0 var(--sp-4)} /* 弹层内的日期选择器展示 */ .picker-box{color:var(--text)} -.del-btn{background:var(--red-soft);color:var(--red)} +.del-btn{background:var(--red-soft);color:var(--red-ink)} -/* 管理任务 */ -.mtask-row{display:flex;align-items:center;gap:16rpx;padding:20rpx 0;border-bottom:1rpx solid var(--line)} +/* 管理任务 / 管理宠物 */ +.mtask-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)} .mtask-body{flex:1;min-width:0} -.mtask-b{font-size:28rpx;font-weight:700} -.mtask-pri{margin-left:12rpx;font-size:20rpx;font-weight:700;color:var(--primary-dark);background:#FFF2DC;padding:2rpx 12rpx;border-radius:999rpx} -.mtask-p{margin-top:6rpx;font-size:24rpx;color:var(--muted)} -.mtask-op{flex:none;font-size:26rpx;font-weight:700;color:var(--primary-dark);padding:8rpx 18rpx;background:#FBFAF8;border-radius:16rpx} -.mtask-op.del{color:var(--red)} -.mtask-form-row{display:flex;align-items:center;gap:16rpx} -.mtask-form-row .seg-btn{flex:none;padding:0 28rpx;height:72rpx;line-height:72rpx} +.mtask-b{font-size:var(--fs-md);font-weight:var(--fw-b)} +.mtask-pri{ + margin-left:var(--sp-2);font-size:var(--fs-cap);font-weight:var(--fw-b); + color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-2);border-radius:var(--r-full); +} +.mtask-p{margin-top:6rpx;font-size:var(--fs-sm);color:var(--muted)} +.mtask-op{ + flex:none;font-size:var(--fs-sm);font-weight:var(--fw-b);color:var(--primary-dark); + padding:var(--sp-1) var(--sp-3);background:var(--surface-2);border-radius:var(--r-xs); +} +.mtask-op.del{color:var(--red-ink)} +.mtask-form-row{display:flex;align-items:center;gap:var(--sp-3)} +/* 这条覆写了全局 .seg-btn 的高度,全局值变化时要一起看 */ +.mtask-form-row .seg-btn{flex:none;padding:0 var(--sp-4);height:var(--h-sm);line-height:var(--h-sm)} .mtask-form-row .picker-box{flex:1} /* 图片选择器 */ -.img-picker{display:flex;flex-wrap:wrap;gap:16rpx} -.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:20rpx;overflow:hidden} +.img-picker{display:flex;flex-wrap:wrap;gap:var(--sp-3)} +.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:var(--r-sm);overflow:hidden} .img-thumb image{width:100%;height:100%} -.img-del{position:absolute;top:0;right:0;width:40rpx;height:40rpx;line-height:38rpx;text-align:center;background:rgba(0,0,0,.55);color:#fff;font-size:30rpx;border-radius:0 20rpx 0 20rpx} -.img-add{width:160rpx;height:160rpx;border-radius:20rpx;border:2rpx dashed #D8CFC4;display:flex;align-items:center;justify-content:center;font-size:56rpx;color:#B9AE9F;background:#FBFAF8} +.img-del{ + position:absolute;top:0;right:0;width:44rpx;height:44rpx; + display:flex;align-items:center;justify-content:center; + background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm); +} +.img-add{ + width:160rpx;height:160rpx;border-radius:var(--r-sm);border:2rpx dashed #D8CFC4; + display:flex;align-items:center;justify-content:center;color:var(--muted2);background:var(--surface-2); +} /* 导出健康档案 */ .export-box{ - max-height:560rpx;overflow-y:auto;background:#FBFAF8;border-radius:28rpx; - padding:24rpx;font-size:25rpx;line-height:1.7;color:#4A4A46;white-space:pre-line; - border:1rpx solid var(--line);margin-bottom:24rpx; + max-height:560rpx;overflow-y:auto;background:var(--surface-2);border-radius:var(--r-md); + padding:var(--sp-4);font-size:var(--fs-sm);line-height:1.7;color:var(--text-2);white-space:pre-line; + border:1rpx solid var(--line);margin-bottom:var(--sp-4); } .export-box::-webkit-scrollbar{width:0;height:0;display:none} -.mine-tag{margin-left:10rpx;font-size:20rpx;font-weight:700;color:var(--primary-dark);background:#FFF2DC;padding:2rpx 10rpx;border-radius:999rpx} +.mine-tag{ + margin-left:var(--sp-1);font-size:var(--fs-cap);font-weight:var(--fw-b); + color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-1);border-radius:var(--r-full); +} + +/* 离屏画布:挪到可视区外而不是 display:none,否则 selectorQuery 取不到节点 */ +.poster-canvas{position:fixed;left:-9999rpx;top:0;width:600rpx;height:840rpx} + +/* ── 评论:头 + 滚动列表 + 常驻输入栏 ── */ +.sheet.sheet-cmp{display:flex;flex-direction:column;height:82vh;padding-bottom:0} +.cmp-head{ + flex:none;text-align:center;padding:0 0 var(--sp-3); + font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);border-bottom:1rpx solid var(--line); +} +/* 空态:图标 + 两行字,居中在列表区,不再是一行字飘在大片留白里 */ +.cmp-empty{ + display:flex;flex-direction:column;align-items:center;justify-content:center; + padding:120rpx 0;color:var(--muted); +} +.cmp-empty-ic{ + width:140rpx;height:140rpx;border-radius:50%;margin-bottom:var(--sp-4); + display:flex;align-items:center;justify-content:center; + background:var(--primary-soft);color:var(--primary); +} +.cmp-empty-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text-2)} +.cmp-empty-p{margin-top:var(--sp-1);font-size:var(--fs-sm);color:var(--muted2)} +.cmp-list{flex:1;min-height:0;padding:var(--sp-3) var(--sp-5) 0} +.cmp-dock{ + flex:none;background:#fff;border-top:1rpx solid var(--line); + padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom)); + transition:padding-bottom .2s ease; +} + +.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0} +.cm-av{ + width:64rpx;height:64rpx;border-radius:50%;flex:none; + display:flex;align-items:center;justify-content:center; + background:var(--primary-soft);color:var(--primary-ink); + font-size:var(--fs-md);font-weight:var(--fw-b); +} +.cm-av-sm{width:48rpx;height:48rpx;font-size:var(--fs-sm)} +.cm-body{flex:1;min-width:0} +.cm-name{font-size:var(--fs-sm);color:var(--muted)} +.cm-at{color:var(--muted2)} +.cm-text{margin-top:6rpx;font-size:var(--fs-md);line-height:1.55;word-break:break-word} +.cm-imgs{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} +.cm-img{width:150rpx;height:150rpx;border-radius:var(--r-sm)} +.cm-meta{display:flex;align-items:center;gap:var(--sp-4);margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted2)} +.cm-reply-btn{color:var(--muted)} +.cm-del{color:var(--red-ink)} +.cm-replies{margin-top:var(--sp-2)} +.cm-reply{display:flex;gap:var(--sp-2);padding:var(--sp-2) 0} +.cm-more{margin-top:4rpx;font-size:var(--fs-cap);color:var(--muted)} + +/* 「回复 @xxx」的提示条 */ +.cm-replying{ + display:inline-flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2); + padding:4rpx var(--sp-3);border-radius:var(--r-full); + background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap); +} +.cm-cancel{display:flex;align-items:center} +/* 收起态:底部一条细条,点了才展开成大面板(小红书那套) */ +.cm-collapsed{ + flex:none;display:flex;align-items:center;gap:var(--sp-3); + background:#fff;border-top:1rpx solid var(--line); + padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom)); +} +.cm-collapsed-pill{ + flex:1;min-width:0;height:76rpx;line-height:76rpx;padding:0 var(--sp-5); + border-radius:var(--r-full);background:var(--surface-2);color:var(--muted2); + font-size:var(--fs-md);overflow:hidden;white-space:nowrap;text-overflow:ellipsis; +} +.cm-collapsed-pill.has{color:var(--text)} +.cm-collapsed-ic{flex:none;display:flex;align-items:center;color:var(--muted)} + +/* 展开态:大写评论面板 */ +.cm-dock-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-2)} +.cm-dock-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.cm-dock-close{display:flex;align-items:center;color:var(--muted);padding:var(--sp-1)} +.cm-ta{ + width:100%;box-sizing:border-box;min-height:200rpx;max-height:520rpx;height:auto; + padding:var(--sp-4);background:var(--surface-2);border-radius:var(--r-md); + font-size:var(--fs-lg);line-height:1.5; +} +.cm-toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-3)} +.cm-pic{flex:none;display:flex;align-items:center;color:var(--muted)} +.cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)} +.cm-send{ + flex:none;height:76rpx;padding:0 var(--sp-6);border-radius:var(--r-full); + display:flex;align-items:center;justify-content:center; + background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease; +} +.cm-send.on{background:var(--cta)} diff --git a/pets-fe/components/fab/fab.js b/pets-fe/components/fab/fab.js index f8e3323..0a094d5 100644 --- a/pets-fe/components/fab/fab.js +++ b/pets-fe/components/fab/fab.js @@ -1,5 +1,12 @@ Component({ options: { addGlobalClass: true }, + properties: { + // 之前漏声明,icon 一直没传进来,pt-icon 收到空值退到 note 兜底字形, + // 首页那个「文档」图标就是这么来的 + icon: { type: String, value: 'plus' }, + // 首页快速记录用肉垫造型(呼应「肉垫计划」),社区发帖仍用普通图标 + paw: { type: Boolean, value: false }, + }, methods: { onTap() { this.triggerEvent('tap'); diff --git a/pets-fe/components/fab/fab.json b/pets-fe/components/fab/fab.json index a89ef4d..a88211e 100644 --- a/pets-fe/components/fab/fab.json +++ b/pets-fe/components/fab/fab.json @@ -1,4 +1,6 @@ { "component": true, - "usingComponents": {} + "usingComponents": { + "pt-icon": "/components/pt-icon/index" + } } diff --git a/pets-fe/components/fab/fab.wxml b/pets-fe/components/fab/fab.wxml index 7e54a8b..2b85ff4 100644 --- a/pets-fe/components/fab/fab.wxml +++ b/pets-fe/components/fab/fab.wxml @@ -1 +1,11 @@ -AI + + + + + + + + + + + diff --git a/pets-fe/components/fab/fab.wxss b/pets-fe/components/fab/fab.wxss index 87ab330..c3bb974 100644 --- a/pets-fe/components/fab/fab.wxss +++ b/pets-fe/components/fab/fab.wxss @@ -1,10 +1,28 @@ +/* 统一暖橘圆钮:首页肉垫和社区发帖用同一套主题色 */ .fab{ position:fixed;right:36rpx;bottom:calc(190rpx + env(safe-area-inset-bottom)); - width:116rpx;height:116rpx;border-radius:44rpx; - background:linear-gradient(135deg,#78A6E8,#9FC0F5); - color:#fff;font-weight:900;font-size:40rpx; + width:112rpx;height:112rpx;border-radius:50%; + background:linear-gradient(150deg,#FFB865,#F5943E); + color:#fff; display:flex;align-items:center;justify-content:center; - box-shadow:0 28rpx 60rpx rgba(120,166,232,.32); + box-shadow:0 12rpx 28rpx rgba(214,138,52,.42),0 2rpx 6rpx rgba(214,138,52,.3); z-index:60; } -.fab:active{transform:scale(.96)} +.fab:active{transform:scale(.94)} + +/* 肉垫造型:和基础一致的暖橘圆钮,中间一只奶白肉垫 */ +.fab-paw{width:116rpx;height:116rpx} + +.paw{position:relative;width:66rpx;height:60rpx} +/* 掌垫:上窄下宽的圆角心形块,坐在下方 */ +.paw .pad{ + position:absolute;left:50%;bottom:0;transform:translateX(-50%); + width:44rpx;height:36rpx;background:#FFF7EC; + border-radius:48% 48% 44% 44%/62% 62% 40% 40%; +} +/* 四个脚趾垫,沿上方弧线排开,外侧两个更低、内侧两个更高 */ +.paw .bean{position:absolute;background:#FFF7EC;border-radius:50%} +.paw .bean-1{width:15rpx;height:20rpx;left:0;top:20rpx;transform:rotate(-20deg)} +.paw .bean-2{width:16rpx;height:22rpx;left:18rpx;top:5rpx;transform:rotate(-7deg)} +.paw .bean-3{width:16rpx;height:22rpx;right:18rpx;top:5rpx;transform:rotate(7deg)} +.paw .bean-4{width:15rpx;height:20rpx;right:0;top:20rpx;transform:rotate(20deg)} diff --git a/pets-fe/components/nav-bar/nav-bar.json b/pets-fe/components/nav-bar/nav-bar.json index a89ef4d..a88211e 100644 --- a/pets-fe/components/nav-bar/nav-bar.json +++ b/pets-fe/components/nav-bar/nav-bar.json @@ -1,4 +1,6 @@ { "component": true, - "usingComponents": {} + "usingComponents": { + "pt-icon": "/components/pt-icon/index" + } } diff --git a/pets-fe/components/nav-bar/nav-bar.wxml b/pets-fe/components/nav-bar/nav-bar.wxml index 813387d..230b1ca 100644 --- a/pets-fe/components/nav-bar/nav-bar.wxml +++ b/pets-fe/components/nav-bar/nav-bar.wxml @@ -1,6 +1,6 @@ - + {{title}} diff --git a/pets-fe/components/nav-bar/nav-bar.wxss b/pets-fe/components/nav-bar/nav-bar.wxss index b1320d4..b6fc5ec 100644 --- a/pets-fe/components/nav-bar/nav-bar.wxss +++ b/pets-fe/components/nav-bar/nav-bar.wxss @@ -1,19 +1,33 @@ :host{display:block} + +/* 原来是一整条半透明色块,底边和页面渐变硬碰硬,屏幕上横着一道接缝。 + 改成上实下虚的渐变,并且用同一条 mask 把毛玻璃也一起淡出—— + 只淡化背景色而不淡化 blur 的话,接缝会从「色差」变成「糊边」,一样难看。 */ .nav-wrap{ position:fixed;top:0;left:0;right:0;z-index:100; - background:rgba(248,246,242,.82); - backdrop-filter:blur(28rpx); - -webkit-backdrop-filter:blur(28rpx); + background:linear-gradient(180deg, + rgba(250,247,242,.94) 0%, + rgba(250,247,242,.88) 58%, + rgba(250,247,242,0) 100%); + backdrop-filter:blur(20rpx); + -webkit-backdrop-filter:blur(20rpx); + -webkit-mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%); + mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%); } .nav-inner{ position:relative;display:flex;align-items:center;justify-content:center; } .nav-title{ - font-size:32rpx;font-weight:900;color:var(--text); - max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; + font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text); + letter-spacing:.5rpx; + max-width:56%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } +/* 返回键做成有边界的圆形,和右上角胶囊在视觉上对称 */ .nav-back{ - position:absolute;left:20rpx;top:50%;transform:translateY(-50%); - width:64rpx;height:64rpx;display:flex;align-items:center;justify-content:center; - font-size:52rpx;line-height:1;color:var(--text); + position:absolute;left:var(--sp-3);top:50%;transform:translateY(-50%); + width:60rpx;height:60rpx;border-radius:50%; + display:flex;align-items:center;justify-content:center; + color:var(--text);background:rgba(255,255,255,.72); + box-shadow:0 4rpx 12rpx rgba(70,48,25,.06); } +.nav-back:active{background:rgba(255,255,255,.95)} diff --git a/pets-fe/components/pet-switch/pet-switch.js b/pets-fe/components/pet-switch/pet-switch.js index 076f6e7..8005212 100644 --- a/pets-fe/components/pet-switch/pet-switch.js +++ b/pets-fe/components/pet-switch/pet-switch.js @@ -2,6 +2,11 @@ const store = require('../../utils/store.js'); Component({ options: { addGlobalClass: true }, + properties: { + // 「添加」这一格不是每个页面都该有:计划页/记录页只是在既有宠物之间切, + // 建档入口在首页和「我的」就够了,到处摆一个会让这条 chip 变得很长 + showAdd: { type: Boolean, value: true }, + }, data: { pets: [], currentId: 0, diff --git a/pets-fe/components/pet-switch/pet-switch.json b/pets-fe/components/pet-switch/pet-switch.json index a89ef4d..a88211e 100644 --- a/pets-fe/components/pet-switch/pet-switch.json +++ b/pets-fe/components/pet-switch/pet-switch.json @@ -1,4 +1,6 @@ { "component": true, - "usingComponents": {} + "usingComponents": { + "pt-icon": "/components/pt-icon/index" + } } diff --git a/pets-fe/components/pet-switch/pet-switch.wxml b/pets-fe/components/pet-switch/pet-switch.wxml index feea1fb..5ddbe39 100644 --- a/pets-fe/components/pet-switch/pet-switch.wxml +++ b/pets-fe/components/pet-switch/pet-switch.wxml @@ -1,10 +1,7 @@ + - {{item.emoji}}{{item.name}} - - - 添加 - + data-id="{{item.id}}" bindtap="onPick">{{item.name}} + + 添加 diff --git a/pets-fe/components/pet-switch/pet-switch.wxss b/pets-fe/components/pet-switch/pet-switch.wxss index 6c71022..ea5678c 100644 --- a/pets-fe/components/pet-switch/pet-switch.wxss +++ b/pets-fe/components/pet-switch/pet-switch.wxss @@ -1,5 +1,11 @@ .pet-switch{ - display:block;white-space:nowrap;width:100%; + display:block;white-space:nowrap;width:100%;font-size:0; +} +/* chip 是 inline-flex,行内盒默认按 baseline 对齐,不同内容会高低不齐; + 统一固定高度 + vertical-align:middle,彻底对齐。父级 font-size:0 消掉 chip 间的空白基线 */ +.pet-switch .pet-chip{ + margin-right:20rpx;vertical-align:middle; + height:64rpx;padding-top:0;padding-bottom:0;font-size:var(--fs-md); } -.pet-switch .pet-chip{margin-right:20rpx} .pet-switch .pet-chip:last-child{margin-right:0} +.pet-chip-add{color:var(--muted)} diff --git a/pets-fe/components/profile-head/index.js b/pets-fe/components/profile-head/index.js new file mode 100644 index 0000000..e02950b --- /dev/null +++ b/pets-fe/components/profile-head/index.js @@ -0,0 +1,36 @@ +// 个人主页头部。「我的」和「宠友主页」共用同一份渲染——自己看和别人看 +// 长得不一样的话,两边会慢慢长歪,装扮功能也就失去意义了。 +// 差别只有一处:自己看是「装扮主页」,别人看是「关注 TA」。 +Component({ + options: { addGlobalClass: true }, + properties: { + card: { type: Object, value: null }, + // 装扮页的实时预览:只看效果,不要底部那排按钮 + preview: { type: Boolean, value: false }, + }, + data: { initial: '', theme: 'warm' }, + observers: { + card(c) { + if (!c) return; + this.setData({ + // WXML 取不了字符串下标,首字得在 JS 里算 + initial: (c.nickname || '?').slice(0, 1), + theme: c.theme || 'warm', + }); + }, + }, + methods: { + onFollow() { + this.triggerEvent('follow'); + }, + onDecorate() { + this.triggerEvent('decorate'); + }, + onRelations(e) { + this.triggerEvent('relations', { kind: e.currentTarget.dataset.kind }); + }, + onPet(e) { + this.triggerEvent('pet', { id: e.currentTarget.dataset.id }); + }, + }, +}); diff --git a/pets-fe/components/profile-head/index.json b/pets-fe/components/profile-head/index.json new file mode 100644 index 0000000..a88211e --- /dev/null +++ b/pets-fe/components/profile-head/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/components/profile-head/index.wxml b/pets-fe/components/profile-head/index.wxml new file mode 100644 index 0000000..4507357 --- /dev/null +++ b/pets-fe/components/profile-head/index.wxml @@ -0,0 +1,54 @@ + + + + + + + + + + {{initial}} + + {{card.nickname || '毛孩子家长'}}AI + {{card.bio || '这个人很懒,还没写签名'}} + + + {{card.post_count || 0}}帖子 + + {{card.following_count || 0}}关注 + + + {{card.follower_count || 0}}粉丝 + + + + + + {{card.stats.days || 0}}养宠天数 + {{card.stats.records || 0}}累计记录 + {{card.stats.streak || 0}}连续打卡 + + + + + + + + {{item.emoji}} + + {{item.name}} + {{item.stage}} + + + + + + 装扮我的主页 + + + {{card.followed ? '已关注' : '关注 TA'}} + + + + diff --git a/pets-fe/components/profile-head/index.wxss b/pets-fe/components/profile-head/index.wxss new file mode 100644 index 0000000..8c268d6 --- /dev/null +++ b/pets-fe/components/profile-head/index.wxss @@ -0,0 +1,61 @@ +/* 主题只在这里定义两个变量,下面所有着色都走变量。 + 后端存的是 theme key 不是色值,就是为了这套配色能随时在前端改。 */ +.pf{--pf-a:#F5A947;--pf-b:#FFE6BA;--pf-ink:#8A5A18} +.th-warm{--pf-a:#F5A947;--pf-b:#FFE6BA;--pf-ink:#8A5A18} +.th-mint{--pf-a:#3FB98A;--pf-b:#D7F3E7;--pf-ink:#1F6B4E} +.th-sky{--pf-a:#4A9BE0;--pf-b:#D8ECFB;--pf-ink:#245F94} +.th-violet{--pf-a:#9B7BE0;--pf-b:#E8DFFA;--pf-ink:#5B3F9B} +.th-ink{--pf-a:#5A5550;--pf-b:#E4DED7;--pf-ink:#2D2925} + +.pf{ + background:#fff;border-radius:var(--r-lg);overflow:hidden; + box-shadow:var(--sd-1);margin-bottom:var(--sp-5); +} +.pf-bg{height:180rpx;background:linear-gradient(135deg,var(--pf-a),var(--pf-b))} +.pf-bg-img{width:100%;height:100%;display:block} + +.pf-body{padding:0 var(--sp-5) var(--sp-5);text-align:center} +/* 头像压在头图上,负 margin 提上去一半 */ +.pf-av{ + /* 必须定位:头像要压在头图上面,靠 DOM 顺序不够——只要头图是定位元素就会反盖过来 */ + position:relative;z-index:1; + width:144rpx;height:144rpx;margin:-72rpx auto var(--sp-3); + border-radius:50%;background:var(--pf-b);color:var(--pf-ink); + display:flex;align-items:center;justify-content:center; + font-size:var(--fs-xl);font-weight:var(--fw-b); + border:6rpx solid #fff;overflow:hidden;box-shadow:var(--sd-1); +} +.pf-av-img{width:100%;height:100%;display:block} +.pf-name{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} +.pf-bio{ + font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx; + line-height:1.5;word-break:break-all; +} + +.pf-stats{display:flex;margin:var(--sp-4) 0} +.pf-stat{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx} +.pf-n{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} +.pf-l{font-size:var(--fs-cap);color:var(--muted)} + +.pf-keep{ + display:flex;background:var(--pf-b);border-radius:var(--r-md); + padding:var(--sp-3) 0;margin-bottom:var(--sp-4); +} +.pf-keep-i{flex:1;display:flex;flex-direction:column;align-items:center;gap:2rpx} +.pf-kn{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--pf-ink)} +.pf-kl{font-size:var(--fs-cap);color:var(--pf-ink);opacity:.75} + +.pf-wall{white-space:nowrap;margin-bottom:var(--sp-4)} +.pf-pet{ + display:inline-block;width:150rpx;vertical-align:top; + margin-right:var(--sp-3);white-space:normal; +} +.pf-pet:last-child{margin-right:0} +.pf-pet-av{ + width:112rpx;height:112rpx;margin:0 auto 8rpx;border-radius:var(--r-md); + background:var(--surface-2);display:flex;align-items:center;justify-content:center; + font-size:52rpx;overflow:hidden; +} +.pf-pet-img{width:100%;height:100%;display:block} +.pf-pet-n{font-size:var(--fs-cap);font-weight:var(--fw-b);color:var(--text);text-align:center} +.pf-pet-s{font-size:var(--fs-cap);color:var(--muted);text-align:center} diff --git a/pets-fe/components/pt-icon/index.js b/pets-fe/components/pt-icon/index.js new file mode 100644 index 0000000..ef9649c --- /dev/null +++ b/pets-fe/components/pt-icon/index.js @@ -0,0 +1,59 @@ +// 线性图标。图标资源在 styles/iconfont.wxss(mask-image + currentColor)。 +// +// name 可以直接传图标名,也可以传服务端下发的值: +// - 记录类型(HealthRecord.Type,9 值枚举,可靠) +// - 文章分类(Article.Category) +// - 历史 emoji(老数据里 icon 字段存的就是 emoji,只能兜底映射) +// 三层都命不中时退到 fallback(默认 note),绝不留空白。 + +// 记录类型 / 文章分类 → 图标名 +const BY_TYPE = { + weight: 'weight', + poop: 'poop', + food: 'food', + symptom: 'symptom', + medicine: 'medicine', + vaccine: 'vaccine', + deworm: 'deworm', + cost: 'cost', + photo: 'photo', + note: 'doc', +}; + +// 老数据里存的 emoji → 图标名。新数据不再写 emoji,但历史记录得能正常显示 +const BY_EMOJI = { + '⚖️': 'weight', '⚖': 'weight', + '💩': 'poop', + '🍽️': 'food', '🍽': 'food', '🍚': 'food', + '🤒': 'symptom', '🌡️': 'symptom', + '💊': 'medicine', + '💰': 'cost', '💸': 'cost', + '📷': 'photo', '📸': 'photo', + '💉': 'vaccine', + '🛡️': 'deworm', '🛡': 'deworm', + '📄': 'doc', '📋': 'doc', + '📝': 'note', '✍️': 'note', '✍': 'note', + '🐱': 'cat', '🐶': 'dog', '🐾': 'community', + '🔔': 'bell', '⚙️': 'settings', '⚙': 'settings', + '📅': 'plan', '📊': 'report', '🏠': 'home', + '⚠️': 'warn', '📖': 'book', '📚': 'book', +}; + +Component({ + options: { addGlobalClass: true, virtualHost: true }, + properties: { + name: { type: String, value: '' }, + size: { type: Number, value: 32 }, // rpx + color: { type: String, value: '' }, // 留空则跟随父级文字色 + fallback: { type: String, value: 'note' }, + }, + data: { cls: '', style: '' }, + observers: { + 'name,size,color,fallback': function (name, size, color, fallback) { + const n = BY_TYPE[name] || BY_EMOJI[name] || name || fallback; + const style = + 'width:' + size + 'rpx;height:' + size + 'rpx' + (color ? ';background-color:' + color : ''); + this.setData({ cls: 'pt-i pt-i-' + n, style }); + }, + }, +}); diff --git a/pets-fe/components/pt-icon/index.json b/pets-fe/components/pt-icon/index.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/pets-fe/components/pt-icon/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/pets-fe/components/pt-icon/index.wxml b/pets-fe/components/pt-icon/index.wxml new file mode 100644 index 0000000..eaf7f4e --- /dev/null +++ b/pets-fe/components/pt-icon/index.wxml @@ -0,0 +1 @@ + diff --git a/pets-fe/components/pt-icon/index.wxss b/pets-fe/components/pt-icon/index.wxss new file mode 100644 index 0000000..cab0df5 --- /dev/null +++ b/pets-fe/components/pt-icon/index.wxss @@ -0,0 +1,17 @@ +/* 图标资源在组件内 import:这样 mask 定义和用它的节点在同一个样式作用域, + 不受组件样式隔离影响,custom-tab-bar 这种没开 addGlobalClass 的地方也能直接用。 */ +@import "../../styles/iconfont.wxss"; + +.pt-i { + display: inline-block; + flex: none; + background-color: currentColor; + -webkit-mask-image: var(--m); + mask-image: var(--m); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} diff --git a/pets-fe/components/seg-tabs/index.js b/pets-fe/components/seg-tabs/index.js new file mode 100644 index 0000000..207862d --- /dev/null +++ b/pets-fe/components/seg-tabs/index.js @@ -0,0 +1,38 @@ +// 横向切换控件。此前有三套长得像但尺寸各不同的实现: +// plan 的 .inline-tabs(灰槽 + 白块)、community 的 .feed-tab(白药丸 + 橙选中 + 阴影)、 +// record 的 .rf-chip(白药丸 + 橙选中 + 无阴影,高度还差 10rpx) +// 这里合成一个:variant="solid" 对应灰槽白块,variant="pill" 对应横滑药丸。 +// +// items 支持两种写法: +// ['推荐','关注','经验'] —— current 传下标 +// [{key:'',label:'全部'},{key:'weight',label:'体重'}] —— current 传 key +Component({ + options: { addGlobalClass: true }, + properties: { + items: { type: Array, value: [] }, + current: { type: null, value: 0 }, + variant: { type: String, value: 'pill' }, + }, + data: { list: [] }, + observers: { + 'items,current': function (items, current) { + const byKey = typeof current === 'string'; + const list = (items || []).map((it, i) => { + const obj = it && typeof it === 'object'; + const key = obj ? it.key : i; + return { + key, + label: obj ? it.label : it, + active: byKey ? key === current : i === Number(current), + }; + }); + this.setData({ list }); + }, + }, + methods: { + onTap(e) { + const { index, key } = e.currentTarget.dataset; + this.triggerEvent('change', { index: Number(index), key }); + }, + }, +}); diff --git a/pets-fe/components/seg-tabs/index.json b/pets-fe/components/seg-tabs/index.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/pets-fe/components/seg-tabs/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/pets-fe/components/seg-tabs/index.wxml b/pets-fe/components/seg-tabs/index.wxml new file mode 100644 index 0000000..0b303f1 --- /dev/null +++ b/pets-fe/components/seg-tabs/index.wxml @@ -0,0 +1,9 @@ + + {{item.label}} + + + + {{item.label}} + diff --git a/pets-fe/components/seg-tabs/index.wxss b/pets-fe/components/seg-tabs/index.wxss new file mode 100644 index 0000000..102f21c --- /dev/null +++ b/pets-fe/components/seg-tabs/index.wxss @@ -0,0 +1,21 @@ +.st{white-space:nowrap} + +/* 横滑药丸:数量不定、可滚动(社区分类、记录类型筛选) */ +.st-pill{padding:0 0 var(--sp-4)} +.st-pill .st-item{ + display:inline-block;height:var(--h-sm);line-height:var(--h-sm);padding:0 var(--sp-4); + margin-right:var(--sp-2);border-radius:var(--r-full);border:1rpx solid var(--line); + background:#fff;color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-b); +} +.st-pill .st-item.on{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)} + +/* 灰槽白块:数量固定的互斥视图(计划页三段) */ +.st-solid{ + display:flex;gap:var(--sp-1);padding:var(--sp-1);border-radius:var(--r-md); + background:#EFE8DE;margin-bottom:var(--sp-4); +} +.st-solid .st-item{ + flex:1;height:var(--h-sm);line-height:var(--h-sm);text-align:center;border-radius:var(--r-sm); + color:var(--muted);font-size:var(--fs-md);font-weight:var(--fw-b); +} +.st-solid .st-item.on{background:#fff;color:var(--primary-dark)} diff --git a/pets-fe/components/stat-ring/index.js b/pets-fe/components/stat-ring/index.js new file mode 100644 index 0000000..b62c511 --- /dev/null +++ b/pets-fe/components/stat-ring/index.js @@ -0,0 +1,41 @@ +// 完成度环。用 SVG data-uri 画(和 record 页的体重折线图同一套路),不用 canvas: +// canvas 在 scroll-view 里的层级、滚动跟随和截图行为全是坑。 +const TAU = 2 * Math.PI; +const R = 42; // viewBox 100 下的半径,留出描边宽度 + +function ringSvg(percent, stroke, track, fill) { + const c = TAU * R; + const p = Math.max(0, Math.min(100, Number(percent) || 0)); + const off = c * (1 - p / 100); + const circle = (color, dash) => + ''; + return ( + '' + + circle(track, '') + + '' + + circle(fill, ' stroke-dasharray="' + c.toFixed(1) + '" stroke-dashoffset="' + off.toFixed(1) + '"') + + '' + ); +} + +Component({ + options: { addGlobalClass: true }, + properties: { + percent: { type: Number, value: 0 }, + size: { type: Number, value: 180 }, // rpx + stroke: { type: Number, value: 8 }, // viewBox 单位,即直径的百分比 + track: { type: String, value: '#FFE6BA' }, + fill: { type: String, value: '#F5A947' }, + }, + data: { src: '', boxStyle: '' }, + observers: { + 'percent,size,stroke,track,fill': function (percent, size, stroke, track, fill) { + const svg = ringSvg(percent, stroke, track, fill); + this.setData({ + src: 'data:image/svg+xml,' + encodeURIComponent(svg), + boxStyle: 'width:' + size + 'rpx;height:' + size + 'rpx', + }); + }, + }, +}); diff --git a/pets-fe/components/stat-ring/index.json b/pets-fe/components/stat-ring/index.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/pets-fe/components/stat-ring/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/pets-fe/components/stat-ring/index.wxml b/pets-fe/components/stat-ring/index.wxml new file mode 100644 index 0000000..3864936 --- /dev/null +++ b/pets-fe/components/stat-ring/index.wxml @@ -0,0 +1,4 @@ + + + + diff --git a/pets-fe/components/stat-ring/index.wxss b/pets-fe/components/stat-ring/index.wxss new file mode 100644 index 0000000..b3d6b91 --- /dev/null +++ b/pets-fe/components/stat-ring/index.wxss @@ -0,0 +1,6 @@ +.ring{position:relative;flex:none} +.ring-img{width:100%;height:100%;display:block} +.ring-center{ + position:absolute;left:0;top:0;right:0;bottom:0; + display:flex;flex-direction:column;align-items:center;justify-content:center; +} diff --git a/pets-fe/custom-tab-bar/index.js b/pets-fe/custom-tab-bar/index.js index fb6b191..eb0883b 100644 --- a/pets-fe/custom-tab-bar/index.js +++ b/pets-fe/custom-tab-bar/index.js @@ -1,20 +1,33 @@ +const LIST = [ + { pagePath: '/pages/home/home', text: '首页', icon: 'home' }, + { pagePath: '/pages/report/report', text: '报告', icon: 'report' }, + { pagePath: '/pages/community/community', text: '社区', icon: 'community' }, + { pagePath: '/pages/mine/mine', text: '我的', icon: 'user' }, +]; + Component({ - data: { - selected: 0, - hidden: false, - list: [ - { pagePath: '/pages/home/home', text: '首页', icon: '🏠' }, - { pagePath: '/pages/plan/plan', text: '计划', icon: '📅' }, - { pagePath: '/pages/record/record', text: '记录', icon: '✍️' }, - { pagePath: '/pages/report/report', text: '报告', icon: '📊' }, - { pagePath: '/pages/community/community', text: '社区', icon: '🐾' } - ] + data: { selected: 0, hidden: false, list: LIST }, + lifetimes: { + attached() { + this.syncSelected(); + }, }, methods: { + // 高亮由当前页面路径算出来,页面那边不写死 0/1/2/3/4, + // 调 tab 顺序只改上面的 LIST 就够了。 + // + // 触发时机必须由页面 onShow 推(见 utils/tabbar.js):自定义 tabBar 不在 + // 页面节点树里,组件自己的 pageLifetimes.show 拿不到可靠时机,实测切到 + // 「我的」时高亮还留在「首页」。 + syncSelected() { + const pages = getCurrentPages(); + const cur = pages[pages.length - 1]; + if (!cur) return; + const i = LIST.findIndex((t) => t.pagePath === '/' + cur.route); + if (i >= 0) this.setData({ selected: i }); + }, switchTab(e) { - const idx = e.currentTarget.dataset.index; - const path = this.data.list[idx].pagePath; - wx.switchTab({ url: path }); - } - } + wx.switchTab({ url: LIST[e.currentTarget.dataset.index].pagePath }); + }, + }, }); diff --git a/pets-fe/custom-tab-bar/index.json b/pets-fe/custom-tab-bar/index.json index a89ef4d..f1339ee 100644 --- a/pets-fe/custom-tab-bar/index.json +++ b/pets-fe/custom-tab-bar/index.json @@ -1,4 +1,6 @@ { "component": true, - "usingComponents": {} + "usingComponents": { + "pt-icon": "../components/pt-icon/index" + } } diff --git a/pets-fe/custom-tab-bar/index.wxml b/pets-fe/custom-tab-bar/index.wxml index 0fcc290..a28608b 100644 --- a/pets-fe/custom-tab-bar/index.wxml +++ b/pets-fe/custom-tab-bar/index.wxml @@ -1,7 +1,7 @@ diff --git a/pets-fe/custom-tab-bar/index.wxss b/pets-fe/custom-tab-bar/index.wxss index fbf9177..dffc2b9 100644 --- a/pets-fe/custom-tab-bar/index.wxss +++ b/pets-fe/custom-tab-bar/index.wxss @@ -3,14 +3,14 @@ height:calc(108rpx + env(safe-area-inset-bottom)); padding-bottom:calc(6rpx + env(safe-area-inset-bottom)); background:rgba(255,255,255,.94); - border-top:1rpx solid #EEE6DC; + /* CSS 变量是继承属性,样式隔离只挡选择器匹配、不挡继承,所以这里能直接吃 page 上的 token */ + border-top:1rpx solid var(--line,#EEE6DC); display:flex; backdrop-filter:blur(32rpx); -webkit-backdrop-filter:blur(32rpx); } .tab{ flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8rpx; - color:#8D8277;font-size:24rpx; + color:var(--muted,#8D8277);font-size:24rpx; } -.tab-icon{font-size:42rpx;line-height:1} -.tab.active{color:#DD8420;font-weight:900} +.tab.active{color:var(--primary-dark,#DD8420);font-weight:600} diff --git a/pets-fe/pages/addrecord/addrecord.js b/pets-fe/pages/addrecord/addrecord.js new file mode 100644 index 0000000..5b6843e --- /dev/null +++ b/pets-fe/pages/addrecord/addrecord.js @@ -0,0 +1,164 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const upload = require('../../utils/upload.js'); +const recordTypes = require('../../utils/recordTypes.js'); +const { toastErr } = require('../../utils/ui.js'); + +function pad2(n) { + return n < 10 ? '0' + n : '' + n; +} +function today() { + const d = new Date(); + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} +// "2026-07-30" → "2026.07.30" +function fmtDate(s) { + return (s || '').replace(/-/g, '.'); +} +// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默回退成 +// time.Now()——选「昨天」会存成今天且不报错。时区要带真实偏移:转成 UTC 的 Z 形式 +// 落库再转回本地时会把边界日期挪一天。取正午同样是为了远离日界。 +function dayToISO(day) { + if (!day) return ''; + const off = -new Date().getTimezoneOffset(); + const sign = off >= 0 ? '+' : '-'; + const a = Math.abs(off); + return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60); +} + +Page({ + data: { + code: '', + type: {}, + fields: [], + values: [], // 和 fields 同下标 + pet: {}, + date: '', + dateLabel: '', + note: '', + images: [], + saving: false, + }, + onLoad(q) { + const code = (q && q.type) || ''; + const d = today(); + this.setData({ code, date: d, dateLabel: fmtDate(d) }); + store + .ready() + .then(() => recordTypes.load()) + .then(() => this.applyType()) + .catch((e) => toastErr(e)); + }, + applyType() { + const t = recordTypes.get(this.data.code); + if (!t) { + wx.showToast({ title: '这个记录类型不存在了', icon: 'none' }); + return setTimeout(() => wx.navigateBack(), 800); + } + const fields = t.fields || []; + // 单选默认不预选:预选了用户不点也会存下一个他没确认过的值 + this.setData({ type: t, fields, values: fields.map(() => ''), pet: store.getPet() || {} }); + }, + // 多宠时切换记到哪只身上。用 actionSheet 而不是自己画一个选择器 + pickPet() { + const pets = store.getPets() || []; + if (pets.length < 2) return; + wx.showActionSheet({ + itemList: pets.map((p) => p.name), + success: (r) => { + const p = pets[r.tapIndex]; + store.switchPet(p.id); + this.setData({ pet: p }); + }, + fail: () => {}, + }); + }, + onDate(e) { + this.setData({ date: e.detail.value, dateLabel: fmtDate(e.detail.value) }); + }, + onField(e) { + this.setData({ ['values[' + e.currentTarget.dataset.index + ']']: e.detail.value }); + }, + onPickOpt(e) { + const { index, val } = e.currentTarget.dataset; + // 再点一下取消选择——单选组里没有「不选」按钮,只能靠反选 + this.setData({ ['values[' + index + ']']: this.data.values[index] === val ? '' : val }); + }, + onNote(e) { + this.setData({ note: e.detail.value }); + }, + onPickImage() { + upload + .chooseAndUploadImage() + .then((f) => this.setData({ images: this.data.images.concat([f]) })) + .catch((e) => { + if (e && e.canceled) return; + toastErr(e, '上传失败'); + }); + }, + onRemoveImage(e) { + const imgs = this.data.images.slice(); + imgs.splice(e.currentTarget.dataset.index, 1); + this.setData({ images: imgs }); + }, + + // 按字段配置组装请求体。哪个字段落到 num_value / category 完全由后端的 + // to_category / kind 决定,前端不认识「体重」「金额」这些业务词 + buildBody() { + const { fields, values } = this.data; + const body = { type: this.data.code, occurred_at: dayToISO(this.data.date) }; + const parts = []; + fields.forEach((f, i) => { + const v = (values[i] || '').toString().trim(); + if (!v) return; + if (f.kind === 'number') { + const n = parseFloat(v); + if (!isNaN(n)) { + body.num_value = n; + parts.push(v + (f.unit || '')); + } + return; + } + if (f.to_category) body.category = v; + parts.push(v); + }); + body.title = parts.length ? this.data.type.label + ':' + parts.join(' ') : this.data.type.label; + body.description = (this.data.note || '').trim(); + const img = this.data.images[0]; + if (img) { + body.image_file_id = img.id; + body.image_url = img.url; + } + return body; + }, + // 必填校验:数值字段空着存下来是一条没有数值的体重记录,趋势图上是个洞 + missingField() { + const { fields, values } = this.data; + for (let i = 0; i < fields.length; i++) { + if (fields[i].kind === 'number' && !(values[i] || '').toString().trim()) { + return fields[i].label; + } + } + return ''; + }, + onSave() { + if (this.data.saving) return; + const id = store.currentPetId(); + if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' }); + const miss = this.missingField(); + if (miss) return wx.showToast({ title: '请填写' + miss, icon: 'none' }); + + this.setData({ saving: true }); + api + .createRecord(id, this.buildBody()) + .then(() => { + this.setData({ saving: false }); + wx.showToast({ title: '已记录', icon: 'success' }); + setTimeout(() => wx.navigateBack(), 600); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, +}); diff --git a/pets-fe/pages/addrecord/addrecord.json b/pets-fe/pages/addrecord/addrecord.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/addrecord/addrecord.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/addrecord/addrecord.wxml b/pets-fe/pages/addrecord/addrecord.wxml new file mode 100644 index 0000000..0d3c982 --- /dev/null +++ b/pets-fe/pages/addrecord/addrecord.wxml @@ -0,0 +1,70 @@ + + + + + + + + 记录宠物 + {{pet.name || '选择'}} + + + + + 记录时间 + {{dateLabel}} + + + + + + + + + + + {{item.label}} + + {{item.unit}} + + + + {{item.label}} + + + + + {{item.label}} + + {{o}} + + + + + + + 描述 + + + + + + + + + + + + + + + + + {{saving ? '保存中…' : '保存'}} + diff --git a/pets-fe/pages/addrecord/addrecord.wxss b/pets-fe/pages/addrecord/addrecord.wxss new file mode 100644 index 0000000..9b4511f --- /dev/null +++ b/pets-fe/pages/addrecord/addrecord.wxss @@ -0,0 +1,50 @@ +/* 底部固定保存键,内容要让开它 */ +.ar-body{padding-bottom:calc(180rpx + env(safe-area-inset-bottom))} +.ar-card{padding:0 var(--pad-x)} + +.ar-row{ + display:flex;align-items:center;gap:var(--sp-3); + min-height:112rpx;border-bottom:1rpx solid var(--line); +} +.ar-last,.ar-row:last-child{border-bottom:0} +.ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)} +.ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)} +.ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)} + +/* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */ +.ar-opt-block{padding:var(--sp-4) 0;border-bottom:1rpx solid var(--line)} +.ar-opts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)} +.ar-opt{ + padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full); + background:var(--surface-2);border:1rpx solid var(--line); + font-size:var(--fs-sm);color:var(--text-2); +} +.ar-opt.on{ + background:var(--primary-soft);border-color:var(--primary); + color:var(--primary-ink);font-weight:var(--fw-b); +} + +.ar-h{font-size:var(--fs-md);font-weight:var(--fw-b);padding:var(--sp-4) 0 var(--sp-2)} +.ar-ta{width:100%;min-height:200rpx;font-size:var(--fs-md);line-height:1.6} + +.ar-media{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0 var(--sp-4)} +.ar-thumb{position:relative;width:132rpx;height:132rpx;border-radius:var(--r-sm);overflow:hidden} +.ar-thumb image{width:100%;height:100%} +.ar-del{ + position:absolute;top:0;right:0;width:40rpx;height:40rpx; + display:flex;align-items:center;justify-content:center; + background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm); +} +.ar-add{ + width:132rpx;height:132rpx;border-radius:var(--r-sm); + background:var(--surface-2);color:var(--muted2); + display:flex;align-items:center;justify-content:center; +} + +.ar-foot{ + position:fixed;left:0;right:0;bottom:0;z-index:50; + padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom)); + background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06); +} +.ar-busy{opacity:.6} diff --git a/pets-fe/pages/article/article.js b/pets-fe/pages/article/article.js index 8896cb3..8fcc26e 100644 --- a/pets-fe/pages/article/article.js +++ b/pets-fe/pages/article/article.js @@ -19,7 +19,7 @@ Page({ }, // 从文章跳到对应的记录入口 goRecord() { - wx.switchTab({ url: '/pages/record/record' }); + wx.navigateTo({ url: '/pages/record/record' }); }, onShareAppMessage() { const a = this.data.article; diff --git a/pets-fe/pages/article/article.json b/pets-fe/pages/article/article.json index 6c6953c..e8b68a8 100644 --- a/pets-fe/pages/article/article.json +++ b/pets-fe/pages/article/article.json @@ -1,6 +1,7 @@ { "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar" + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" }, "navigationStyle": "custom", "navigationBarTitleText": "新手知识" diff --git a/pets-fe/pages/article/article.wxml b/pets-fe/pages/article/article.wxml index a5e7dde..c1a7ec1 100644 --- a/pets-fe/pages/article/article.wxml +++ b/pets-fe/pages/article/article.wxml @@ -1,20 +1,20 @@ - - 加载中… - {{error}} + + 加载中… + {{error}} - {{article.icon}} + {{article.title}} {{article.description}} {{article.content}} - 这篇文章还没有正文。 + 这篇文章还没有正文。 diff --git a/pets-fe/pages/article/article.wxss b/pets-fe/pages/article/article.wxss index 3581c41..ff5f6af 100644 --- a/pets-fe/pages/article/article.wxss +++ b/pets-fe/pages/article/article.wxss @@ -1,17 +1,15 @@ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} -.page-body{padding:8rpx 36rpx calc(60rpx + env(safe-area-inset-bottom))} - -.hint{padding:60rpx 0;text-align:center;color:var(--muted);font-size:28rpx} - -.art-head{padding:12rpx 0 28rpx} -.art-icon{font-size:72rpx;line-height:1;margin-bottom:16rpx} -.art-title{font-size:44rpx;font-weight:800;line-height:1.35;letter-spacing:-.5rpx} -.art-desc{margin-top:14rpx;color:var(--muted);font-size:27rpx;line-height:1.6} +.art-head{padding:var(--sp-2) 0 var(--sp-4)} +.art-icon{ + width:96rpx;height:96rpx;border-radius:var(--r-md);margin-bottom:var(--sp-3); + display:flex;align-items:center;justify-content:center; + background:var(--primary-soft);color:var(--primary-ink); +} +.art-title{font-size:var(--fs-2xl);font-weight:var(--fw-b);line-height:1.35;letter-spacing:-.5rpx} +.art-desc{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md);line-height:1.6} .art-content{ - font-size:29rpx;line-height:1.85;color:#3D352E; + font-size:var(--fs-md);line-height:1.85;color:var(--text-2); white-space:pre-line; /* 正文用真实换行排版 */ } -.art-foot{margin-top:28rpx;text-align:center;color:var(--muted);font-size:23rpx} +.art-foot{margin-top:var(--sp-4);text-align:center;color:var(--muted);font-size:var(--fs-cap)} diff --git a/pets-fe/pages/community/community.js b/pets-fe/pages/community/community.js index 537345f..001371b 100644 --- a/pets-fe/pages/community/community.js +++ b/pets-fe/pages/community/community.js @@ -1,6 +1,22 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); const { toastErr } = require('../../utils/ui.js'); +const { syncTabBar } = require('../../utils/tabbar.js'); + +// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义 +function fmtAgo(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const min = Math.floor((Date.now() - d.getTime()) / 60000); + if (min < 1) return '刚刚'; + if (min < 60) return min + ' 分钟前'; + if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前'; + if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前'; + const p = (n) => (n < 10 ? '0' + n : '' + n); + const sameYear = d.getFullYear() === new Date().getFullYear(); + return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate()); +} const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' }; @@ -22,6 +38,7 @@ function mapPost(p) { user_id: p.user_id, followed: !!p.followed, is_self: !!p.is_self, + timeText: fmtAgo(p.created_at), }; } @@ -36,6 +53,7 @@ Page({ sheetPostId: '', loaded: false, loadErr: '', + intoView: '', }, onLoad() { this._unsub = store.subscribe((pet) => this.setData({ pet })); @@ -44,9 +62,7 @@ Page({ if (this._unsub) this._unsub(); }, onShow() { - if (typeof this.getTabBar === 'function' && this.getTabBar()) { - this.getTabBar().setData({ selected: 4 }); - } + syncTabBar(this); store .ready() .then(() => { @@ -67,10 +83,17 @@ Page({ toastErr(e); }); }, + // 来自 seg-tabs 的 change 事件 switchFeedTab(e) { - this.setData({ feedIdx: e.currentTarget.dataset.index }); + // 切分类必须回到顶部:不然从「推荐」滚到一半切到「关注」, + // 看到的是新列表的中间,会以为内容错乱了 + this.setData({ feedIdx: Number(e.detail.index), intoView: 'feed-top' }); this.loadPosts(); }, + // 滚动时清掉锚点,否则下次再设同一个值不会触发 + onFeedScroll() { + if (this.data.intoView) this.setData({ intoView: '' }); + }, likePost(e) { const i = e.currentTarget.dataset.index; const post = this.data.posts[i]; @@ -79,7 +102,7 @@ Page({ .then((res) => { this.setData({ [`posts[${i}].like_count`]: res.like_count, [`posts[${i}].liked`]: true }); }) - .catch(() => {}); + .catch((e) => toastErr(e, '点赞失败')); }, openComments(e) { this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true }); @@ -97,11 +120,17 @@ Page({ onCommented() { this.loadPosts(); }, + // 点头像或昵称进 TA 的主页。关注做了却没有主页,关注完就石沉大海 + goUser(e) { + const id = e.currentTarget.dataset.id; + if (id) wx.navigateTo({ url: `/pages/user/user?id=${id}` }); + }, goLearn() { wx.navigateTo({ url: '/pages/learn/learn' }); }, - onFab() { - this.setData({ sheetType: 'ai', sheetPostId: '', sheetShow: true }); + // 社区的悬浮键本来就该是发帖,不是记录(原来它跳 AI 聊天页) + onCreatePost() { + this.setData({ sheetType: 'createPost', sheetShow: true }); }, // 长按自己的帖子可删除 onLongPressPost(e) { diff --git a/pets-fe/pages/community/community.json b/pets-fe/pages/community/community.json index 24ea6ea..7f17b0c 100644 --- a/pets-fe/pages/community/community.json +++ b/pets-fe/pages/community/community.json @@ -2,6 +2,8 @@ "usingComponents": { "nav-bar": "/components/nav-bar/nav-bar", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "fab": "/components/fab/fab", + "pt-icon": "/components/pt-icon/index", + "seg-tabs": "/components/seg-tabs/index" } } diff --git a/pets-fe/pages/community/community.wxml b/pets-fe/pages/community/community.wxml index 06c4830..e68084c 100644 --- a/pets-fe/pages/community/community.wxml +++ b/pets-fe/pages/community/community.wxml @@ -3,26 +3,21 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } - + + + + + + - - 宠友圈 - 和宠友交流、晒宠、求助与经验分享。 - - - - {{item}} - - - - - - + - {{item.author_emoji}} - {{item.author_name}}AI宠友 + {{item.author_emoji}} + + {{item.author_name}}AI + {{item.timeText}} + {{item.tag}} @@ -35,22 +30,31 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } - {{item.liked ? '♥' : '♡'}} {{item.like_count}} - 💬 {{item.comment_count}} - + + + {{item.like_count || '点赞'}} + + + + {{item.comment_count || '评论'}} + + - + {{loadErr}} 点击重试 - + {{feedTabs[feedIdx] === '关注' ? '还没有关注的人,去「推荐」里关注几个宠友吧 🐾' : '还没有帖子,来发第一条吧 🐾'}} - + diff --git a/pets-fe/pages/community/community.wxss b/pets-fe/pages/community/community.wxss index e25dce0..23a07b2 100644 --- a/pets-fe/pages/community/community.wxss +++ b/pets-fe/pages/community/community.wxss @@ -1,56 +1,19 @@ -.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))} -.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800} -.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx} +.compose-card{margin-bottom:var(--sp-4)} -.feed-tabs{white-space:nowrap;padding:0 0 24rpx} -.feed-tab{ - display:inline-block;border:1rpx solid var(--line);background:#fff;border-radius:999rpx; - height:68rpx;line-height:68rpx;padding:0 26rpx;color:var(--muted);font-size:26rpx;font-weight:900; - box-shadow:0 12rpx 32rpx rgba(70,48,25,.05);margin-right:16rpx; -} -.feed-tab.active{color:var(--primary-dark);background:#FFF4E4;border-color:#FFD39A} - -.compose-card{margin-bottom:28rpx} - -.post-card{background:#fff;border-radius:48rpx;box-shadow:var(--shadow);padding:30rpx;margin-bottom:28rpx} -.post-head{display:flex;align-items:center;gap:20rpx;margin-bottom:22rpx} -.post-avatar{ - width:84rpx;height:84rpx;border-radius:32rpx;background:#FFF0D8; - display:flex;align-items:center;justify-content:center;font-size:44rpx;flex:none; -} -.post-user{flex:1} -.pu-b{font-size:28rpx;display:block;font-weight:700} -.ai-tag{display:inline-block;margin-left:10rpx;padding:0 10rpx;border-radius:8rpx;background:#EAF0FF;color:#3B6BE5;font-size:20rpx;font-weight:700;vertical-align:1rpx} -.pu-s{font-size:24rpx;color:var(--muted)} -.post-content{font-size:28rpx;line-height:1.62;color:#443D37;margin-bottom:24rpx} - -.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12rpx;margin-bottom:24rpx} -.photo-tile{ - aspect-ratio:1;border-radius:32rpx;background:linear-gradient(135deg,#FFE6BA,#FFF8ED); - display:flex;align-items:center;justify-content:center;font-size:60rpx; -} - -.post-actions{display:flex;justify-content:space-around;border-top:1rpx solid var(--line);padding-top:20rpx} -.pa-btn{color:var(--muted);font-weight:900;font-size:26rpx} -.pa-btn.liked{color:var(--red)} - -.empty-hint{text-align:center;color:var(--muted);font-size:26rpx;padding:80rpx 0} - -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} - -/* 帖子分享按钮:用微信原生 open-type=share,需抹掉 button 默认外观 */ -.pa-share{ - background:transparent;border:none;padding:0;margin:0;line-height:inherit; - font-size:26rpx;font-weight:900;color:var(--muted); -} -.pa-share::after{border:none} /* 关注按钮 */ -.follow-btn{ - flex:none;font-size:22rpx;font-weight:700;padding:6rpx 18rpx;border-radius:999rpx; - color:var(--primary-dark);background:#FFF2DC;border:1rpx solid #FFD39A; + +.retry-btn{ + margin-top:var(--sp-3);display:inline-block;padding:var(--sp-2) var(--sp-6);border-radius:var(--r-full); + background:var(--primary-soft);color:var(--primary-dark);font-weight:var(--fw-b);font-size:var(--fs-sm); +} + +/* 分类标签吸顶,发布按钮收进同一条:原来那个大黑按钮独占 124rpx, + 而它是低频操作,不该在每屏都占着首屏 */ +.feed-bar{display:flex;align-items:flex-start;gap:var(--sp-3)} +.feed-bar .st{flex:1;min-width:0} +.feed-post{ + flex:none;display:flex;align-items:center;gap:4rpx; + height:var(--h-sm);padding:0 var(--sp-3);border-radius:var(--r-full); + background:var(--cta);color:#fff;font-size:var(--fs-sm);font-weight:var(--fw-b); } -.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)} -.retry-btn{margin-top:20rpx;display:inline-block;padding:12rpx 36rpx;border-radius:999rpx;background:#FFF2DC;color:var(--primary-dark);font-weight:700;font-size:26rpx} diff --git a/pets-fe/pages/decorate/decorate.js b/pets-fe/pages/decorate/decorate.js new file mode 100644 index 0000000..c2680a8 --- /dev/null +++ b/pets-fe/pages/decorate/decorate.js @@ -0,0 +1,129 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const { toastErr } = require('../../utils/ui.js'); + +// key 要和后端 service.UserThemes 一致,色值只在 profile-head 的 wxss 里 +const THEMES = [ + { key: 'warm', label: '暖橙' }, + { key: 'mint', label: '薄荷' }, + { key: 'sky', label: '天青' }, + { key: 'violet', label: '藕紫' }, + { key: 'ink', label: '墨灰' }, +]; + +Page({ + data: { + themes: THEMES, + form: { bio: '', bg_url: '', bg_file_id: '', theme: 'warm', show_pets: true }, + bioLen: 0, + preview: null, + saving: false, + }, + onLoad() { + store + .ready() + .then(() => this.loadCard()) + .catch((e) => toastErr(e)); + }, + loadCard() { + const uid = (store.getUser() || {}).id; + if (!uid) return; + api + .userCard(uid) + .then((card) => { + this.setData({ + form: { + bio: card.bio || '', + bg_url: card.bg_url || '', + bg_file_id: '', // 没重新上传就不带这个字段,避免把已有头图覆盖成空 + theme: card.theme || 'warm', + show_pets: card.show_pets !== false, + }, + bioLen: (card.bio || '').length, + }); + this._card = card; + this.syncPreview(); + }) + .catch((e) => toastErr(e)); + }, + // 预览喂给 profile-head 的就是一份改过的 card,组件那边不用知道自己在被预览 + syncPreview() { + const f = this.data.form; + this.setData({ + preview: Object.assign({}, this._card || {}, { + bio: f.bio, + bg_url: f.bg_url, + theme: f.theme, + show_pets: f.show_pets, + }), + }); + }, + pickBg() { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sizeType: ['compressed'], + success: (res) => { + const path = res.tempFiles[0].tempFilePath; + // 先用本地路径顶上,别让用户对着转圈等上传 + this.setData({ 'form.bg_url': path }); + this.syncPreview(); + wx.showLoading({ title: '上传中…' }); + api + .uploadFile(path) + .then((file) => { + wx.hideLoading(); + this.setData({ 'form.bg_file_id': file.id, 'form.bg_url': file.url }); + this.syncPreview(); + }) + .catch((e) => { + wx.hideLoading(); + this.setData({ 'form.bg_url': (this._card && this._card.bg_url) || '' }); + this.syncPreview(); + toastErr(e, '上传失败'); + }); + }, + }); + }, + clearBg() { + // 空字符串是「清掉头图」的显式信号,和「没传」不是一回事 + this.setData({ 'form.bg_url': '', 'form.bg_file_id': '' }); + this.syncPreview(); + }, + pickTheme(e) { + this.setData({ 'form.theme': e.currentTarget.dataset.key }); + this.syncPreview(); + }, + onBio(e) { + this.setData({ 'form.bio': e.detail.value, bioLen: e.detail.value.length }); + this.syncPreview(); + }, + onShowPets(e) { + this.setData({ 'form.show_pets': e.detail.value }); + this.syncPreview(); + }, + save() { + if (this.data.saving) return; + const f = this.data.form; + const body = { bio: f.bio, theme: f.theme, show_pets: f.show_pets }; + // 只有真的动过头图才带 bg_file_id:新上传带新 id,点了「恢复默认渐变」带空串, + // 两者都没有就不传,让后端保持原样 + const had = (this._card && this._card.bg_url) || ''; + if (f.bg_file_id) body.bg_file_id = f.bg_file_id; + else if (had && !f.bg_url) body.bg_file_id = ''; + + this.setData({ saving: true }); + api + .updateDecoration(body) + .then((user) => { + this.setData({ saving: false }); + store.setUser(user); // 返回的就是改完的 user,别再多请求一次 + wx.showToast({ title: '已保存', icon: 'success' }); + setTimeout(() => wx.navigateBack(), 600); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, +}); diff --git a/pets-fe/pages/decorate/decorate.json b/pets-fe/pages/decorate/decorate.json new file mode 100644 index 0000000..3151e63 --- /dev/null +++ b/pets-fe/pages/decorate/decorate.json @@ -0,0 +1,8 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index", + "profile-head": "/components/profile-head/index" + }, + "navigationBarTitleText": "装扮主页" +} diff --git a/pets-fe/pages/decorate/decorate.wxml b/pets-fe/pages/decorate/decorate.wxml new file mode 100644 index 0000000..d2c9529 --- /dev/null +++ b/pets-fe/pages/decorate/decorate.wxml @@ -0,0 +1,53 @@ + + + + + + + + + 头图 + + + + + 点这里换一张头图 + + + 恢复默认渐变 + + + + 主题色 + + + + {{item.label}} + + + + + + + 个性签名 + {{bioLen}}/60 + + + + + + + + 展示我的毛孩子 + 关掉后别人访问你的主页看不到宠物名片墙 + + + + + + {{saving ? '保存中…' : '保存'}} + + diff --git a/pets-fe/pages/decorate/decorate.wxss b/pets-fe/pages/decorate/decorate.wxss new file mode 100644 index 0000000..65ef28e --- /dev/null +++ b/pets-fe/pages/decorate/decorate.wxss @@ -0,0 +1,30 @@ +.dc-bg{ + height:240rpx;border-radius:var(--r-md);overflow:hidden; + background:var(--surface-2);display:flex;align-items:center;justify-content:center; +} +.dc-bg-img{width:100%;height:100%;display:block} +.dc-bg-empty{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);color:var(--muted)} +.dc-hint{font-size:var(--fs-cap)} +.dc-clear{margin-top:var(--sp-3);text-align:center} + +.dc-themes{display:flex;gap:var(--sp-3)} +.dc-theme{ + flex:1;display:flex;flex-direction:column;align-items:center;gap:8rpx; + padding:var(--sp-3) 0;border-radius:var(--r-sm);border:2rpx solid transparent; +} +.dc-theme.on{border-color:var(--primary);background:var(--primary-soft)} +.dc-swatch{width:64rpx;height:64rpx;border-radius:50%;box-shadow:var(--sd-1)} +.dc-tname{font-size:var(--fs-cap);color:var(--muted)} +.dc-theme.on .dc-tname{color:var(--primary-dark);font-weight:var(--fw-b)} + +/* 色值和 profile-head 的主题保持一致,改那边这里也要跟着改 */ +.sw-warm{background:linear-gradient(135deg,#F5A947,#FFE6BA)} +.sw-mint{background:linear-gradient(135deg,#3FB98A,#D7F3E7)} +.sw-sky{background:linear-gradient(135deg,#4A9BE0,#D8ECFB)} +.sw-violet{background:linear-gradient(135deg,#9B7BE0,#E8DFFA)} +.sw-ink{background:linear-gradient(135deg,#5A5550,#E4DED7)} + +.dc-count{font-size:var(--fs-cap);color:var(--muted)} + +.dc-row{display:flex;align-items:center;gap:var(--sp-4)} +.dc-row-main{flex:1;min-width:0} diff --git a/pets-fe/pages/expense/expense.js b/pets-fe/pages/expense/expense.js new file mode 100644 index 0000000..a4a44ab --- /dev/null +++ b/pets-fe/pages/expense/expense.js @@ -0,0 +1,158 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const recordTypes = require('../../utils/recordTypes.js'); +const { toastErr } = require('../../utils/ui.js'); + +function pad2(n) { + return n < 10 ? '0' + n : '' + n; +} +function today() { + const d = new Date(); + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} +// 同 addrecord:后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期解不出来会 +// 静默回退成 now;时区要带真实偏移,转 UTC 的 Z 形式落库会把边界日期挪一天 +function dayToISO(day) { + if (!day) return ''; + const off = -new Date().getTimezoneOffset(); + const sign = off >= 0 ? '+' : '-'; + const a = Math.abs(off); + return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60); +} +function fmtDay(iso) { + if (!iso) return ''; + const d = new Date(iso); + return d.getMonth() + 1 + '月' + d.getDate() + '日'; +} + +const PAGE = 50; + +Page({ + data: { + bill: {}, + cats: [], + cat: '', + customOn: false, + customCat: '', + amount: '', + date: '', + note: '', + list: [], + saving: false, + }, + onLoad() { + this.setData({ date: today() }); + store + .ready() + .then(() => recordTypes.load()) + .then(() => this.loadCats()) + .catch((e) => toastErr(e)); + }, + onShow() { + if (this._inited) this.reload(); + this._inited = true; + }, + onReady() { + this.reload(); + }, + // 类别预置读 record_types 里 cost 的字段配置——后台改一处, + // 这页和 addrecord 同时生效,不会出现两套类别把账单统计切开 + loadCats() { + const t = recordTypes.get('cost'); + const opt = ((t && t.fields) || []).find((f) => f.kind === 'options'); + this.setData({ cats: (opt && opt.options) || [] }); + }, + reload() { + const id = store.currentPetId(); + if (!id) return; + api + .getBill(id, 'month') + .then((bill) => this.setData({ bill: bill || {} })) + .catch(() => {}); + api + .getRecords(id, { type: 'cost', page: 1, pageSize: PAGE }) + .then((res) => { + const now = new Date(); + const list = (res.list || []) + // 只显示本月:接口按时间倒序返回,这里前端切一刀, + // 不为了「本月」再加一个后端参数 + .filter((r) => { + const d = new Date(r.occurred_at); + return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); + }) + .map((r) => ({ ...r, timeText: fmtDay(r.occurred_at) })); + this.setData({ list }); + }) + .catch(() => {}); + }, + onAmount(e) { + this.setData({ amount: e.detail.value }); + }, + onPickCat(e) { + const v = e.currentTarget.dataset.val; + this.setData({ cat: this.data.cat === v ? '' : v, customOn: false, customCat: '' }); + }, + toggleCustom() { + const on = !this.data.customOn; + this.setData({ customOn: on, cat: on ? '' : this.data.cat }); + }, + onCustomCat(e) { + this.setData({ customCat: e.detail.value }); + }, + onDate(e) { + this.setData({ date: e.detail.value }); + }, + onNote(e) { + this.setData({ note: e.detail.value }); + }, + onSave() { + if (this.data.saving) return; + const id = store.currentPetId(); + if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' }); + const amt = parseFloat(this.data.amount); + if (!(amt > 0)) return wx.showToast({ title: '填一个金额', icon: 'none' }); + const cat = (this.data.customOn ? this.data.customCat : this.data.cat).trim(); + if (!cat) return wx.showToast({ title: '选一个类别', icon: 'none' }); + + this.setData({ saving: true }); + // 落的是 type='cost' 的普通记录:金额进 num_value、类别进 category, + // 和 addrecord 走同一条路,报告页的账单聚合照样认 + api + .createRecord(id, { + type: 'cost', + title: '记账:' + amt + '元 ' + cat, + num_value: amt, + category: cat, + description: (this.data.note || '').trim(), + occurred_at: dayToISO(this.data.date), + }) + .then(() => { + this.setData({ saving: false, amount: '', note: '', customCat: '', customOn: false, cat: '' }); + wx.showToast({ title: '已记下', icon: 'success' }); + this.reload(); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, + // 长按删除。流水里记错一笔很常见,不给删的话账单就一直是错的 + onDelete(e) { + const id = e.currentTarget.dataset.id; + wx.showModal({ + title: '删掉这笔?', + content: '删了账单统计会跟着变。', + confirmColor: '#EE7D73', + success: (r) => { + if (!r.confirm) return; + api + .deleteRecord(id) + .then(() => this.reload()) + .catch((err) => toastErr(err, '删除失败')); + }, + }); + }, + onShareAppMessage() { + return { title: '养宠一个月花了多少?', path: '/pages/expense/expense' }; + }, +}); diff --git a/pets-fe/pages/expense/expense.json b/pets-fe/pages/expense/expense.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/expense/expense.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/expense/expense.wxml b/pets-fe/pages/expense/expense.wxml new file mode 100644 index 0000000..61da736 --- /dev/null +++ b/pets-fe/pages/expense/expense.wxml @@ -0,0 +1,71 @@ + + + + + + + {{bill.period || '本月'}}花销 + ¥{{bill.total || 0}} + 单笔最高 ¥{{bill.max_single}} + + + {{item.category}}¥{{item.amount}} + + + + 这个月还没记过花销 + + + + + 记一笔 + + + ¥ + + + + 类别 + + {{item}} + + + 自定义 + + + + + 日期 + + {{date}} + + + 备注 + + + + {{saving ? '保存中…' : '记下这笔'}} + + + + + + + 本月流水 + {{list.length}} 笔 + + + + {{item.category || '未分类'}} + {{item.timeText}}{{item.description ? '|' + item.description : ''}} + + ¥{{item.num_value}} + + 还没有流水。长按某笔可以删除。 + + + diff --git a/pets-fe/pages/expense/expense.wxss b/pets-fe/pages/expense/expense.wxss new file mode 100644 index 0000000..13d59de --- /dev/null +++ b/pets-fe/pages/expense/expense.wxss @@ -0,0 +1,34 @@ +.ex-body{padding-bottom:var(--pad-b-plain)} + +/* 合计卡:数字要够大,这页第一眼就该看到花了多少 */ +.ex-sum{text-align:center} +.ex-sum-k{font-size:var(--fs-sm);color:var(--muted)} +.ex-sum-n{font-size:72rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.2;margin:var(--sp-1) 0} +.ex-sum-s{font-size:var(--fs-cap);color:var(--muted2)} +.ex-cats{margin-top:var(--sp-5);text-align:left} +.ex-cat{margin-bottom:var(--sp-3)} +.ex-cat:last-child{margin-bottom:0} +.ex-cat-top{display:flex;justify-content:space-between;font-size:var(--fs-sm);color:var(--text-2);margin-bottom:6rpx} + +/* 金额输入做大,和键盘上的数字对得上 */ +.ex-amt{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-4) 0 var(--sp-2);border-bottom:2rpx solid var(--primary-line)} +.ex-cur{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--primary-dark)} +.ex-in{flex:1;font-size:64rpx;font-weight:var(--fw-b);color:var(--text);height:84rpx} + +.ex-label{font-size:var(--fs-sm);color:var(--muted);margin:var(--sp-4) 0 var(--sp-2)} +.ex-chips{display:flex;flex-wrap:wrap;gap:var(--sp-2)} +.ex-chip{ + padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full); + background:var(--surface-2);border:1rpx solid var(--line); + font-size:var(--fs-sm);color:var(--text-2); +} +.ex-chip.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)} +.ex-chip-add{display:inline-flex;align-items:center;gap:4rpx} +.ex-custom{margin-top:var(--sp-3)} +.ex-save{margin-top:var(--sp-5)} +.ex-busy{opacity:.6} + +.ex-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)} +.ex-row:last-of-type{border-bottom:0} +.ex-row-main{flex:1;min-width:0} +.ex-row-amt{flex:none;font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} diff --git a/pets-fe/pages/history/history.js b/pets-fe/pages/history/history.js new file mode 100644 index 0000000..5205d9e --- /dev/null +++ b/pets-fe/pages/history/history.js @@ -0,0 +1,149 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const { toastErr } = require('../../utils/ui.js'); +const recordTypes = require('../../utils/recordTypes.js'); + +function pad(n) { + return n < 10 ? '0' + n : '' + n; +} +function fmtTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + const now = new Date(); + const hm = pad(d.getHours()) + ':' + pad(d.getMinutes()); + if (d.toDateString() === now.toDateString()) return '今天 ' + hm; + const y = new Date(now.getTime() - 86400000); + if (d.toDateString() === y.toDateString()) return '昨天 ' + hm; + return d.getMonth() + 1 + '月' + d.getDate() + '日'; +} +// 每条记录的分组配色。这页只读历史,引一份轻量的 code→组 映射就够 +function toneOf(type) { + const t = recordTypes.get(type); + return (t && t.tone) || 'tone-1'; +} +function mapRecord(r) { + return { + id: r.id, + // 图标按 type 取(可靠);老数据 icon 字段里的 emoji 只作兜底 + type: r.type || r.icon || '', + tone: toneOf(r.type), + title: r.title, + desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''), + image: r.image_url || '', + }; +} + +// 时间轴类型筛选。常用的一组,不是全部 24 种——seg-tabs 放满会挤成一条难点的横条 +const REC_FILTERS = [ + { key: '', label: '全部' }, + { key: 'weight', label: '体重' }, + { key: 'poop', label: '排便' }, + { key: 'food', label: '饮食' }, + { key: 'symptom', label: '异常' }, + { key: 'vaccine', label: '疫苗' }, + { key: 'cost', label: '记账' }, + { key: 'photo', label: '照片' }, +]; + +const REC_PAGE = 8; + +Page({ + data: { + pet: {}, + timeline: [], + recPage: 1, + recTotal: 0, + recHasMore: false, + recFilters: REC_FILTERS, + recFilterIdx: 0, + }, + onLoad() { + this._unsub = store.subscribe((pet) => { + this.setData({ pet }); + if (!this._inited) return; + this.loadRecords(); + }); + }, + onUnload() { + if (this._unsub) this._unsub(); + }, + onShow() { + // 分组配色要读类型缓存。这页可能直接从报告页进来,缓存没经过首页热过, + // 自己拉一次(load 内部只真请求一次),拉完刷新已有列表的配色 + recordTypes.load().then(() => this.setData({ timeline: this.data.timeline.map((t) => ({ ...t, tone: toneOf(t.type) })) })).catch(() => {}); + store + .ready() + .then(() => { + this._inited = true; + this.setData({ pet: store.getPet() }); + this.loadRecords(); + }) + .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); + }, + curFilter() { + return this.data.recFilters[this.data.recFilterIdx].key; + }, + onFilterTap(e) { + const i = Number(e.detail.index); + if (i === this.data.recFilterIdx) return; + this.setData({ recFilterIdx: i }, () => this.loadRecords()); + }, + loadRecords() { + const id = store.currentPetId(); + if (!id) return; + api + .getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() }) + .then((res) => { + const list = (res.list || []).map(mapRecord); + this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) }); + }) + .catch((e) => toastErr(e)); + }, + loadMoreRecords() { + const id = store.currentPetId(); + if (!id) return; + const next = this.data.recPage + 1; + api + .getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() }) + .then((res) => { + const merged = this.data.timeline.concat((res.list || []).map(mapRecord)); + this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) }); + }) + .catch(() => {}); + }, + collapseRecords() { + this.loadRecords(); + }, + onDeleteRecord(e) { + const r = this.data.timeline[e.currentTarget.dataset.index]; + if (!r || !r.id) return; + wx.showModal({ + title: '删除记录', + content: '确定删除「' + r.title + '」?删除后不可恢复。', + confirmColor: '#D9534F', + success: (res) => { + if (!res.confirm) return; + api + .deleteRecord(r.id) + .then(() => { + wx.showToast({ title: '已删除', icon: 'success' }); + this.loadRecords(); + }) + .catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' })); + }, + }); + }, + previewImage(e) { + const src = e.currentTarget.dataset.src; + if (src) wx.previewImage({ urls: [src], current: src }); + }, + onAddPet() { + wx.navigateTo({ url: '/pages/petform/petform' }); + }, + onShareAppMessage() { + return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' }; + }, + onShareTimeline() { + return { title: '用肉垫计划记录毛孩子的成长' }; + }, +}); diff --git a/pets-fe/pages/history/history.json b/pets-fe/pages/history/history.json new file mode 100644 index 0000000..6b28821 --- /dev/null +++ b/pets-fe/pages/history/history.json @@ -0,0 +1,8 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pet-switch": "/components/pet-switch/pet-switch", + "pt-icon": "/components/pt-icon/index", + "seg-tabs": "/components/seg-tabs/index" + } +} diff --git a/pets-fe/pages/history/history.wxml b/pets-fe/pages/history/history.wxml new file mode 100644 index 0000000..d0db908 --- /dev/null +++ b/pets-fe/pages/history/history.wxml @@ -0,0 +1,36 @@ + + + + + + + + + 还没有毛孩子的档案 + 建好档并记上几笔,这里就会长出完整的记录流水。 + 建一份档案 + + + + + + + + + + + + {{item.title}}{{item.desc}} + + + + 这个分类下还没有记录 + + 长按任意一条可删除 + 查看更多(共 {{recTotal}} 条) + 收起 + + + + diff --git a/pets-fe/pages/history/history.wxss b/pets-fe/pages/history/history.wxss new file mode 100644 index 0000000..8764002 --- /dev/null +++ b/pets-fe/pages/history/history.wxss @@ -0,0 +1,19 @@ +/* 全部记录时间轴。洞察和体重趋势已经搬到报告页,这里只留流水 + 筛选 */ + +.health-timeline{display:flex;flex-direction:column;gap:var(--sp-2)} +.health-event{display:flex;gap:var(--sp-3);background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4)} +/* 底色和文字色由 .tone-N 提供(记录类型决定),这里只管形状 */ +.event-dot{ + width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none; + display:flex;align-items:center;justify-content:center; +} +.he-b{font-size:var(--fs-md);font-weight:var(--fw-b)} +.he-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:6rpx} +.he-body{flex:1;min-width:0} +.he-img{margin-top:var(--sp-2);width:220rpx;height:220rpx;border-radius:var(--r-sm)} + +.tl-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted);font-size:var(--fs-cap)} +.tl-more{ + margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark); + background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b); +} diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index 819e3f3..ee6953c 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -1,41 +1,49 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); const { toastErr } = require('../../utils/ui.js'); - -function todayStr() { - const d = new Date(); - const p = (n) => (n < 10 ? '0' + n : '' + n); - return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); -} +const { syncTabBar } = require('../../utils/tabbar.js'); +const recordTypes = require('../../utils/recordTypes.js'); +const petmeta = require('../../utils/petmeta.js'); function mapTask(t) { - return { - id: t.id, - title: t.title, - sub: t.description, - priority: t.priority, - done: t.done, - sheet: t.sheet_type, - }; + return { id: t.id, title: t.title, sub: t.description, priority: t.priority, done: t.done, sheet: t.sheet_type }; } +// 时间轴上的相对时间。首页关心的是「什么时候记的」,精确到分只在今天有意义 +function fmtWhen(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const p = (n) => (n < 10 ? '0' + n : '' + n); + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) return '今天 ' + p(d.getHours()) + ':' + p(d.getMinutes()); + const y = new Date(now.getTime() - 86400000); + if (d.toDateString() === y.toDateString()) return '昨天 ' + p(d.getHours()) + ':' + p(d.getMinutes()); + return d.getMonth() + 1 + '月' + d.getDate() + '日'; +} + +const TL_PAGE = 10; + Page({ data: { pet: {}, - summary: { greeting: '', insights: [], week: [], advice: '', health_pct: 0, health_status: '正常' }, + zodiac: '', + daysTogether: 0, + sexIcon: '', + sexTone: '', tasks: [], - firstArticle: null, - selectedDate: '', - taskLabel: '今日任务', - todayDate: '', - sheetShow: false, - sheetType: '', + planHint: '', + drift: {}, + needNextPlan: false, + monthCost: 0, + timeline: [], + tlPage: 1, + hasMore: false, }, onLoad() { - this.setData({ todayDate: todayStr() }); this._unsub = store.subscribe((pet) => { - this.setData({ pet }); - // 首次由 onShow 统一加载,这里只处理之后的「切换宠物 / 数据变更」,避免重复请求 + this.applyPet(pet); if (!this._inited) return; this.loadAll(); }); @@ -44,127 +52,182 @@ Page({ if (this._unsub) this._unsub(); }, onShow() { - if (typeof this.getTabBar === 'function' && this.getTabBar()) { - this.getTabBar().setData({ selected: 0 }); - } + syncTabBar(this); + recordTypes.load().catch(() => {}); store .ready() .then(() => { this._inited = true; - this.setData({ pet: store.getPet() }); + this.applyPet(store.getPet()); this.loadAll(); }) .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); }, - loadAll() { - this.loadSummary(); - this.loadTasks(); - if (!this.data.firstArticle) { - api - .listArticles() - .then((list) => { - if (list && list[0]) { - this.setData({ firstArticle: { id: list[0].id, icon: list[0].icon, title: list[0].title, desc: list[0].description } }); - } - }) - .catch(() => {}); - } + // 门面上那几样都是本地算的,不用等接口 + applyPet(pet) { + const p = pet || {}; + const badge = petmeta.genderBadge(p.gender); + this.setData({ + pet: p, + zodiac: petmeta.zodiac(p.birthday), + daysTogether: petmeta.daysTogether(p), + sexIcon: badge.icon, + sexTone: badge.tone, + }); }, - loadSummary() { - const id = store.currentPetId(); - if (!id) return; - api - .homeSummary(id) - .then((summary) => this.setData({ summary })) - .catch((e) => toastErr(e)); + loadAll() { + this.loadTasks(); + this.loadTimeline(1); + this.loadEntries(); + this.loadDrift(); }, loadTasks() { const id = store.currentPetId(); if (!id) return; - const date = this.data.selectedDate || this.data.todayDate; api - .getTasks(id, date) + .getTasks(id) .then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) })) .catch((e) => toastErr(e)); }, - onTapDay(e) { - const { date, active } = e.currentTarget.dataset; - if (!date) return; - const parts = date.split('-'); - this.setData({ - selectedDate: date, - taskLabel: active ? '今日任务' : +parts[1] + '月' + +parts[2] + '日', - }); - this.loadTasks(); + // 两个入口上的副标题:计划还剩几件、本月花了多少。 + // 空着的话入口就是两个没有信息量的图标 + // 阶段漂移检查。放在 loadEntries 里而不是单独一次 —— 它和「计划还剩几件」 + // 都是首页那一屏要的,一起拉省一个来回 + loadDrift() { + const id = store.currentPetId(); + if (!id) return; + api + .petStageDrift(id) + .then((d) => this.setData({ drift: d || {} })) + .catch(() => this.setData({ drift: {} })); }, - backToToday() { - this.setData({ selectedDate: this.data.todayDate, taskLabel: '今日任务' }); - this.loadTasks(); + loadEntries() { + const id = store.currentPetId(); + if (!id) return; + // 副标题优先说「该排下月计划了」——那是有时效的事; + // 本月还剩几件是随时能看的,让位给它 + api + .planMonthStatus(id) + .then((st) => { + const s = st || {}; + this.setData({ + needNextPlan: !!s.need_next, + planHint: s.need_next + ? (s.next_label || '下月') + '计划还没排' + : s.this_undone > 0 + ? '本月还有 ' + s.this_undone + ' 件' + : s.this_month > 0 + ? '本月都做完了' + : '还没有安排', + }); + }) + .catch(() => this.setData({ needNextPlan: false, planHint: '' })); + api + .getBill(id, 'month') + .then((b) => this.setData({ monthCost: (b && b.total) || 0 })) + .catch(() => this.setData({ monthCost: 0 })); + }, + loadTimeline(page) { + const id = store.currentPetId(); + if (!id) return; + api + .getRecords(id, { page, pageSize: TL_PAGE }) + .then((res) => { + const list = (res.list || []).map((r) => ({ + id: r.id, + type: r.type, + tone: (recordTypes.get(r.type) || {}).tone || 'tone-1', + title: r.title, + description: r.description, + image: r.image_url || '', + timeText: fmtWhen(r.occurred_at), + })); + const merged = page === 1 ? list : this.data.timeline.concat(list); + this.setData({ timeline: merged, tlPage: page, hasMore: merged.length < (res.total || 0) }); + }) + .catch(() => {}); + }, + loadMore() { + if (this.data.hasMore) this.loadTimeline(this.data.tlPage + 1); + }, + previewImage(e) { + const url = e.currentTarget.dataset.url; + if (url) wx.previewImage({ urls: [url], current: url }); + }, + onDeleteRecord(e) { + const id = e.currentTarget.dataset.id; + wx.showModal({ + title: '删掉这条记录?', + content: '趋势和统计会跟着变。', + confirmColor: '#EE7D73', + success: (r) => { + if (!r.confirm) return; + api + .deleteRecord(id) + .then(() => this.loadTimeline(1)) + .catch((err) => toastErr(err, '删除失败')); + }, + }); }, onTapTask(e) { const i = e.currentTarget.dataset.index; const t = this.data.tasks[i]; + // 任务关联了记录类型就直接去记那一笔 if (t.sheet) { - this.openSheetType(t.sheet); + wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + t.sheet }); return; } api .toggleTask(t.id) .then((res) => { - this.setData({ [`tasks[${i}].done`]: res.done }); - this.loadSummary(); + const tasks = this.data.tasks.slice(); + tasks[i] = Object.assign({}, tasks[i], { done: res.done }); + this.setData({ tasks }); }) - .catch(() => {}); + .catch((err) => toastErr(err, '操作失败')); }, completeTasks() { const id = store.currentPetId(); if (!id) return; api .completeAllTasks(id) - .then((tasks) => { - this.setData({ tasks: (tasks || []).map(mapTask) }); - this.loadSummary(); - }) - .catch(() => {}); + .then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) })) + .catch((e) => toastErr(e, '操作失败')); }, - openSheet(e) { - this.openSheetType(e.currentTarget.dataset.type); + goPlan() { + wx.navigateTo({ url: '/pages/plan/plan' }); }, - openSheetType(type) { - this.setData({ sheetType: type, sheetShow: true }); + // 去建档页改阶段。改完回来 onShow 会重新拉一次漂移状态,提示自然消失。 + // 不在首页直接改:改阶段之后要不要套新方案是同一件事,放在一起做才连贯 + // 点「一起生活的天数」空态,去建档页补到家日期 + onDaysTap() { + const id = this.data.pet && this.data.pet.id; + if (!this.data.daysTogether && id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); }, - closeSheet() { - // 只关闭。数据变更由 bind:saved 或 store 的宠物变更通知触发刷新, - // 不必每次关弹层(哪怕只是看了眼 AI)都全量重拉一遍 - this.setData({ sheetShow: false }); + // 身份卡:门面卡右上角进,用当前宠物 + goIDCard() { + const id = this.data.pet && this.data.pet.id; + if (id) wx.navigateTo({ url: '/pages/idcard/idcard?id=' + id }); }, - onSheetSave() { - this.loadTasks(); - this.loadSummary(); + goStageUpdate() { + const id = this.data.pet && this.data.pet.id; + if (id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); }, - onAddPet() { - this.openSheetType('addPet'); - }, - onEditPet() { - this.openSheetType('editPet'); - }, - onFab() { - this.openSheetType('ai'); - }, - goProfile() { - wx.navigateTo({ url: '/pages/profile/profile' }); - }, - goLearn() { - wx.navigateTo({ url: '/pages/learn/learn' }); - }, - // 首页那张卡片直接打开这篇文章正文 - openFirstArticle() { - const a = this.data.firstArticle; - if (a && a.id) wx.navigateTo({ url: `/pages/article/article?id=${a.id}` }); - else this.goLearn(); + goExpense() { + wx.navigateTo({ url: '/pages/expense/expense' }); }, goRecord() { - wx.switchTab({ url: '/pages/record/record' }); + wx.navigateTo({ url: '/pages/record/record' }); + }, + onQuickRecord() { + wx.navigateTo({ url: '/pages/record/record' }); + }, + onAddPet() { + wx.navigateTo({ url: '/pages/petform/petform' }); + }, + onHeroTap() { + const id = this.data.pet && this.data.pet.id; + wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') }); }, onShareAppMessage() { return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' }; diff --git a/pets-fe/pages/home/home.json b/pets-fe/pages/home/home.json index 075a8b2..d173c86 100644 --- a/pets-fe/pages/home/home.json +++ b/pets-fe/pages/home/home.json @@ -2,7 +2,7 @@ "usingComponents": { "nav-bar": "/components/nav-bar/nav-bar", "pet-switch": "/components/pet-switch/pet-switch", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "fab": "/components/fab/fab", + "pt-icon": "/components/pt-icon/index" } } diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml index 558e5ac..dc86625 100644 --- a/pets-fe/pages/home/home.wxml +++ b/pets-fe/pages/home/home.wxml @@ -1,100 +1,118 @@ - + + + - - - {{summary.greeting || '你好'}} - 今天也要照顾好 {{pet.name}} 🐾 - - - 🔔 - - - - - - - - - {{pet.emoji}} - - {{pet.name}} - {{pet.age}}|{{pet.weight}}|{{pet.stage}} + + + + + + {{pet.emoji || '🐾'}} + {{sexIcon}} + + + {{pet.name || '还没有毛孩子'}} + + {{pet.breed}} + {{pet.age}} + {{zodiac}} + + + + 身份卡 - {{summary.health_status || '正常'}} - 本周健康完成度{{summary.health_pct}}% - - - {{item.bold}}{{item.text}} - - - - - - {{item.weekday}}{{item.day}} + + + 我们一起生活的第 {{daysTogether}} + 还没填到家日期,一起生活的天数显示 - - + + + + + {{pet.name}}已经 {{drift.age_label}},从{{drift.stored}}进入{{drift.computed}}了 + + {{drift.basis}} + 这个阶段的重点:{{drift.focus}} + {{drift.size}} + 去更新阶段和方案 + + + + + + + + + + + 管理计划 + {{planHint}} + + + + 记花销 + {{monthCost > 0 ? '本月 ¥' + monthCost : '还没记过'}} + + + + + - {{taskLabel}} + 今日任务 + - 管理 - 回今天 - 全部完成 + 管理 + 全部完成 - - - {{item.title}}{{item.sub}} + + + {{item.title}}{{item.sub}} {{item.priority}} - 这天没有任务记录 - - - 快速记录 - 更多 + + + 最近记录 + + 记一笔 + + + + + + - - ⚖️体重 - 💩便便 - 🍽️饮食 - 🤒异常 - 💰消费 - 💊用药 - 📷照片 - 💉疫苗 + + {{item.timeText}} + + {{item.title}} + {{item.description}} + + - - - AI 今日建议 - 问问 AI - - {{summary.advice || '正在生成今日建议…'}} - - - - - 新手内容 - 查看 - - - {{firstArticle.icon}} - {{firstArticle.title}}{{firstArticle.desc}} - + + 还没有记录。点上面的「记一笔」开始, + 体重趋势和健康洞察都会从这些记录里长出来。 + 加载更多 - - + diff --git a/pets-fe/pages/home/home.wxss b/pets-fe/pages/home/home.wxss index 0f96936..543326f 100644 --- a/pets-fe/pages/home/home.wxss +++ b/pets-fe/pages/home/home.wxss @@ -1,61 +1,102 @@ -.page-body{padding:8rpx 36rpx calc(210rpx + env(safe-area-inset-bottom))} - -.top-row{display:flex;align-items:center;justify-content:space-between;margin:4rpx 0 28rpx} -.greet-h2{font-size:44rpx;line-height:1.15;letter-spacing:-.8rpx;font-weight:800} -.greet-p{font-size:26rpx;color:var(--muted);margin-top:10rpx} -.icon-stack{display:flex;gap:18rpx} -.icon-btn{ - width:80rpx;height:80rpx;border-radius:50%;background:#fff;box-shadow:var(--shadow); - font-size:36rpx;display:flex;align-items:center;justify-content:center; -} +/* 门面卡 */ .hero-card{ background: - radial-gradient(circle at 95% 16%, rgba(245,169,71,.25), transparent 30%), - linear-gradient(135deg,#FFFFFF,#FFF5E6); - min-height:340rpx; + radial-gradient(circle at 95% 16%, rgba(245,169,71,.22), transparent 32%), + linear-gradient(135deg,#FFFFFF,#FFF6E9); } -.pet-row{display:flex;align-items:center;gap:26rpx;margin-bottom:28rpx} -.pet-main{flex:1} -.pet-name-b{font-size:42rpx;letter-spacing:-.4rpx;font-weight:800} -.pet-meta{color:var(--muted);font-size:26rpx;margin-top:8rpx} -.insight-row{white-space:nowrap;margin-top:24rpx} -.insight{ - display:inline-block;min-width:224rpx;border-radius:32rpx; - background:rgba(255,255,255,.72);border:1rpx solid rgba(255,255,255,.9); - padding:20rpx;font-size:24rpx;margin-right:16rpx;vertical-align:top; +/* 一周日期条 */ + + +/* ===== 日期带(原计划页「日历」tab 并过来) ===== */ +/* 某天的安排 */ +.dd-title{font-weight:var(--fw-b);font-size:var(--fs-md);margin-bottom:var(--sp-3)} + +/* ===== 接下来 ===== */ + +/* ===== 门面:宠物名片 ===== */ +.hero-card{padding:var(--sp-5)} +.hero-row{display:flex;align-items:center;gap:var(--sp-4)} +.hero-av{ + position:relative;flex:none;width:128rpx;height:128rpx;border-radius:50%; + background:var(--primary-soft);display:flex;align-items:center;justify-content:center; + font-size:60rpx;overflow:visible; } -.insight-b{display:block;font-size:28rpx;margin-bottom:8rpx;font-weight:900} - -.date-strip{display:grid;grid-template-columns:repeat(7,1fr);gap:14rpx;margin-bottom:28rpx} -.day{ - background:#fff;border-radius:32rpx;min-height:116rpx;text-align:center;padding:16rpx 8rpx; - border:1rpx solid var(--line);color:var(--muted);font-size:22rpx; +.hero-av-img{width:100%;height:100%;border-radius:50%} +/* 性别徽章叠在头像右下:一个很省事但很出效果的细节 */ +.hero-sex{ + position:absolute;right:-4rpx;bottom:-4rpx;width:42rpx;height:42rpx;border-radius:50%; + border:4rpx solid #fff;color:#fff;font-size:22rpx;line-height:1; + display:flex;align-items:center;justify-content:center; } -.day-b{display:block;color:var(--text);font-size:30rpx;margin-top:8rpx;font-weight:700} -.day.active{border-color:#FFD39A;background:#FFF4E4;color:var(--primary-dark)} -.day.sel{border-color:var(--primary);background:#FFEFD6;color:var(--primary-dark);box-shadow:0 0 0 2rpx rgba(245,169,71,.35)} -.day.has-dot::after{content:"";display:block;width:10rpx;height:10rpx;border-radius:50%;background:var(--green);margin:8rpx auto 0} -.task-empty{padding:36rpx 8rpx;text-align:center;color:var(--muted);font-size:26rpx} - -.tt-b{font-size:28rpx;font-weight:700} -.tt-p{font-size:24rpx;color:var(--muted);margin-top:8rpx;line-height:1.45} - -.quick-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18rpx} -.quick{ - background:#FBFAF8;border-radius:32rpx;min-height:132rpx; - display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12rpx; - font-weight:900;color:var(--text);font-size:24rpx; +.hero-sex.male{background:#5B9BE8} +.hero-sex.female{background:#EE85A8} +.hero-main{flex:1;min-width:0} +.hero-title{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)} +.hero-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx} +.hero-arrow{flex:none;color:var(--muted2);display:flex;align-items:center} +.hero-days{ + margin-top:var(--sp-4);background:var(--primary-soft);color:var(--primary-ink); + border-radius:var(--r-md);text-align:center;padding:var(--sp-3); + font-size:var(--fs-md);font-weight:var(--fw-b); } -.q-ic{font-size:44rpx} +.hd-n{font-size:var(--fs-lg);margin:0 4rpx} -.ai-banner{background:linear-gradient(135deg,#F2FAF6,#FFFFFF)} -.ai-text{color:#59635D;font-size:28rpx;line-height:1.6} +/* ===== 两个入口 ===== */ +.entry-card{display:flex;padding:var(--sp-5) var(--sp-3)} +.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:6rpx} +.entry-ic{ + width:96rpx;height:96rpx;border-radius:var(--r-md); + display:flex;align-items:center;justify-content:center;margin-bottom:4rpx; +} +.entry-b{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.entry-p{font-size:var(--fs-cap);color:var(--muted)} -.ac-b{font-size:30rpx;font-weight:700} -.ac-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx} +/* ===== 记录时间轴 ===== */ +.tl-head{display:flex;align-items:center;justify-content:space-between;margin:var(--sp-5) 0 var(--sp-3)} +.tl-item{display:flex;gap:var(--sp-3)} +.tl-rail{flex:none;width:56rpx;display:flex;flex-direction:column;align-items:center} +.tl-dot{ + width:56rpx;height:56rpx;border-radius:50%;flex:none; + display:flex;align-items:center;justify-content:center; +} +/* 竖线连起来才像一条时间轴;最后一条的线会被 body 的高度自然截断 */ +.tl-line{flex:1;width:2rpx;background:var(--line);margin:6rpx 0} +.tl-item:last-of-type .tl-line{display:none} +.tl-body{flex:1;min-width:0;padding-bottom:var(--sp-4)} +.tl-when{font-size:var(--fs-cap);color:var(--muted);margin-bottom:6rpx} +.tl-card{background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);padding:var(--sp-4)} +.tl-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.tl-desc{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} +.tl-img{width:100%;height:320rpx;border-radius:var(--r-sm);margin-top:var(--sp-3);display:block} +.tl-more{text-align:center;padding:var(--sp-4);font-size:var(--fs-sm);color:var(--muted)} -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} +.entry-ic{position:relative} +.entry-dot{ + position:absolute;top:-2rpx;right:-2rpx;width:18rpx;height:18rpx; + border-radius:50%;background:var(--red);border:3rpx solid #fff; +} +.entry-p.warn{color:var(--red-ink);font-weight:var(--fw-b)} + +/* 阶段漂移提示。用主色不用红色:这是「可以做一下」不是「出问题了」 */ +.drift-card{border:2rpx solid var(--primary-line);background:var(--primary-soft)} +.drift-top{display:flex;align-items:flex-start;gap:var(--sp-2);color:var(--primary-dark)} +.drift-b{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.5} +.drift-p{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:var(--sp-2)} +.drift-focus{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:6rpx} +.drift-size{font-size:var(--fs-cap);color:var(--primary-dark);opacity:.8;margin-top:6rpx} +.drift-cta{margin-top:var(--sp-4)} + +/* 没填到家日期时的天数空态:压成灰、可点去补 */ +.hero-days-empty{background:var(--surface-2);color:var(--muted)} +.hero-days-empty .hd-n{color:var(--muted)} + +/* 门面卡右上角的身份卡入口。原来那里是个 next 箭头(点整卡进编辑), + 身份卡做成小胶囊,和整卡点击分开 */ +.hero-idcard{ + flex:none;align-self:flex-start;display:flex;align-items:center;gap:4rpx; + padding:8rpx var(--sp-3);border-radius:var(--r-full); + background:var(--primary-soft);color:var(--primary-dark); + font-size:var(--fs-cap);font-weight:var(--fw-b); +} diff --git a/pets-fe/pages/idcard/idcard.js b/pets-fe/pages/idcard/idcard.js new file mode 100644 index 0000000..b0ebe3f --- /dev/null +++ b/pets-fe/pages/idcard/idcard.js @@ -0,0 +1,263 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const { toastErr } = require('../../utils/ui.js'); + +// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大 +const W = 620; +const H = 760; + +// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。 +// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来 +function loadImage(canvas, src) { + return new Promise((resolve) => { + if (!src) return resolve(null); + const img = canvas.createImage(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); +} + +Page({ + data: { + petId: '', + card: null, + cardImg: '', + qrReady: false, + saving: false, + }, + onLoad(q) { + store + .ready() + .then(() => { + const id = (q && q.id) || store.currentPetId(); + if (!id) { + wx.showToast({ title: '先选一只毛孩子', icon: 'none' }); + return; + } + this.setData({ petId: id }); + return api.petIDCard(id); + }) + .then((card) => { + if (!card) return; + this.setData({ card, qrReady: !!card.qr_ready }); + // 布局要等页面渲染完 canvas 节点才拿得到,放 nextTick + wx.nextTick(() => this.draw()); + }) + .catch((e) => toastErr(e)); + }, + + draw() { + wx.createSelectorQuery() + .select('#idCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + const node = res && res[0] && res[0].node; + if (!node) return; + let dpr = 2; + try { + dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2; + } catch (e) { + dpr = 2; + } + node.width = W * dpr; + node.height = H * dpr; + const ctx = node.getContext('2d'); + ctx.scale(dpr, dpr); + + const c = this.data.card || {}; + // 两张远程图都就绪再开画,否则小程序码会画不上或画一半(plan 里点过名的坑) + Promise.all([loadImage(node, c.avatar_url), loadImage(node, c.qr_url)]).then(([avatar, qr]) => { + this.paint(ctx, node, c, avatar, qr); + }); + }); + }, + + paint(ctx, node, c, avatar, qr) { + ctx.clearRect(0, 0, W, H); + + // 外层留白背景 + ctx.fillStyle = '#F3EFE8'; + ctx.fillRect(0, 0, W, H); + + const pad = 30; + const cardW = W - pad * 2; + + // ── 上半张:资料区(蓝紫渐变)── + const topH = 380; + const g1 = ctx.createLinearGradient(pad, pad, W - pad, topH); + g1.addColorStop(0, '#FFF0D6'); + g1.addColorStop(0.5, '#FFE0EC'); + g1.addColorStop(1, '#E6EEFF'); + this.roundRect(ctx, pad, pad, cardW, topH - pad, 28); + ctx.fillStyle = g1; + ctx.fill(); + + // 头像圆 + const avX = pad + 34, avY = pad + 34, avD = 108; + ctx.save(); + ctx.beginPath(); + ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + if (avatar) { + ctx.drawImage(avatar, avX, avY, avD, avD); + } else { + ctx.fillStyle = '#FFD9A3'; + ctx.fillRect(avX, avY, avD, avD); + ctx.fillStyle = '#8A5A18'; + ctx.font = '52px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(c.species === '狗狗' ? '🐶' : '🐱', avX + avD / 2, avY + avD / 2); + } + ctx.restore(); + ctx.textBaseline = 'alphabetic'; + + // 资料行 + const infoX = avX + avD + 28; + let y = pad + 52; + const line = (label, value) => { + ctx.textAlign = 'left'; + ctx.font = '24px sans-serif'; + ctx.fillStyle = '#9A8F82'; + ctx.fillText(label, infoX, y); + ctx.fillStyle = '#2D2925'; + ctx.font = '600 26px sans-serif'; + ctx.fillText(value || '—', infoX + ctx.measureText(label).width + 14, y); + y += 46; + }; + line('姓名', c.name); + // 性别 + 品种放一行 + ctx.textAlign = 'left'; + ctx.font = '24px sans-serif'; + ctx.fillStyle = '#9A8F82'; + ctx.fillText('性别', infoX, y); + ctx.fillStyle = '#2D2925'; + ctx.font = '600 26px sans-serif'; + const gw = ctx.measureText(c.gender || '—').width; + ctx.fillText(c.gender || '—', infoX + 62, y); + if (c.breed) { + ctx.fillStyle = '#9A8F82'; + ctx.font = '24px sans-serif'; + ctx.fillText('品种', infoX + 62 + gw + 24, y); + ctx.fillStyle = '#2D2925'; + ctx.font = '600 26px sans-serif'; + ctx.fillText(c.breed, infoX + 62 + gw + 24 + 62, y); + } + y += 46; + line('出生', c.birthday); + line('介绍', c.intro); + + // 身份 ID:虚线 + 号 + const idY = pad + 300; + ctx.strokeStyle = 'rgba(122,74,8,.22)'; + ctx.lineWidth = 1; + ctx.setLineDash([6, 6]); + ctx.beginPath(); + ctx.moveTo(pad + 34, idY); + ctx.lineTo(W - pad - 34, idY); + ctx.stroke(); + ctx.setLineDash([]); + ctx.textAlign = 'left'; + ctx.font = '22px sans-serif'; + ctx.fillStyle = '#9A8F82'; + ctx.fillText('身份 ID', pad + 34, idY + 34); + ctx.font = '600 26px sans-serif'; + ctx.fillStyle = '#2D2925'; + ctx.fillText(c.id_no || '', pad + 34 + 90, idY + 34); + + // ── 下半张:证件标题 + 小程序码 ── + const botY = topH + 12; + const botH = H - botY - pad; + const g2 = ctx.createLinearGradient(pad, botY, W - pad, botY + botH); + g2.addColorStop(0, '#E8F0FF'); + g2.addColorStop(1, '#F0E8FF'); + this.roundRect(ctx, pad, botY, cardW, botH, 28); + ctx.fillStyle = g2; + ctx.fill(); + + ctx.textAlign = 'left'; + ctx.fillStyle = '#7D89A8'; + ctx.font = '22px sans-serif'; + ctx.fillText('宠 星 人 地 球 移 民', pad + 40, botY + 60); + ctx.fillStyle = '#4A5878'; + ctx.font = '700 52px sans-serif'; + ctx.fillText('居民身份卡', pad + 40, botY + 128); + + ctx.fillStyle = '#8A93A8'; + ctx.font = '22px sans-serif'; + ctx.fillText('签发机构 肉垫计划', pad + 40, botY + 180); + ctx.fillText('有效期限 ' + (c.validity || '永久有效'), pad + 40, botY + 214); + + // 小程序码(未发布时 qr 为 null,画个占位框) + const qrD = 128, qrX = W - pad - 40 - qrD, qrY = botY + botH - 40 - qrD; + ctx.fillStyle = '#fff'; + this.roundRect(ctx, qrX, qrY, qrD, qrD, 12); + ctx.fill(); + if (qr) { + ctx.drawImage(qr, qrX + 8, qrY + 8, qrD - 16, qrD - 16); + } else { + ctx.fillStyle = '#B5A99D'; + ctx.font = '20px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 4); + ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 26); + } + + wx.canvasToTempFilePath( + { + canvas: node, + fileType: 'png', + success: (r) => this.setData({ cardImg: r.tempFilePath }), + fail: () => wx.showToast({ title: '生成失败,稍后再试', icon: 'none' }), + }, + this, + ); + }, + + roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + }, + + onSave() { + if (!this.data.cardImg) return wx.showToast({ title: '还在生成,稍等一下', icon: 'none' }); + if (this.data.saving) return; + this.setData({ saving: true }); + wx.saveImageToPhotosAlbum({ + filePath: this.data.cardImg, + success: () => wx.showToast({ title: '已保存到相册', icon: 'success' }), + fail: (err) => { + const msg = (err && err.errMsg) || ''; + if (msg.indexOf('cancel') >= 0) return; + // 拒过一次后 authorize 不再弹,必须引导去设置页 + if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) { + wx.showModal({ + title: '需要相册权限', + content: '保存身份卡需要允许访问相册,去设置里打开一下?', + confirmText: '去设置', + success: (r) => r.confirm && wx.openSetting({}), + }); + return; + } + wx.showToast({ title: '保存失败', icon: 'none' }); + }, + complete: () => this.setData({ saving: false }), + }); + }, + + onShareAppMessage() { + const c = this.data.card || {}; + return { + title: `${c.name || '我家毛孩子'} 的居民身份卡`, + path: '/pages/idcard/idcard?id=' + this.data.petId, + imageUrl: this.data.cardImg || '', // 用画好的卡当分享封面 + }; + }, +}); diff --git a/pets-fe/pages/idcard/idcard.json b/pets-fe/pages/idcard/idcard.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/idcard/idcard.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/idcard/idcard.wxml b/pets-fe/pages/idcard/idcard.wxml new file mode 100644 index 0000000..8b85f45 --- /dev/null +++ b/pets-fe/pages/idcard/idcard.wxml @@ -0,0 +1,27 @@ + + + + + + + + 正在生成身份卡… + + + + 小程序码要在小程序发布后才会出现,现在先放一个占位。 + + + + + 下载到相册 + + + + + + + + diff --git a/pets-fe/pages/idcard/idcard.wxss b/pets-fe/pages/idcard/idcard.wxss new file mode 100644 index 0000000..be0963d --- /dev/null +++ b/pets-fe/pages/idcard/idcard.wxss @@ -0,0 +1,21 @@ +.ic-body{padding-top:var(--sp-4)} + +/* 画好的卡片图。宽度撑满、按比例高 */ +.ic-stage{border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sd-2);background:#F3EFE8;min-height:600rpx} +.ic-img{width:100%;display:block} +.ic-loading{padding:200rpx 0;text-align:center;color:var(--muted);font-size:var(--fs-sm)} + +.ic-tip{ + margin-top:var(--sp-4);padding:var(--sp-3);border-radius:var(--r-sm); + background:var(--primary-soft);color:var(--primary-ink); + font-size:var(--fs-cap);line-height:1.6;text-align:center; +} + +.ic-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-5)} +.ic-btn{flex:1} +/* button 元素默认有边框和外边距,压平成和 .btn 一样 */ +button.ic-btn{padding:0;line-height:normal} +button.ic-btn::after{border:none} + +/* 离屏 canvas:挪出可视区,只用来生成图 */ +.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:760px} diff --git a/pets-fe/pages/learn/learn.js b/pets-fe/pages/learn/learn.js index fc92938..c8c500e 100644 --- a/pets-fe/pages/learn/learn.js +++ b/pets-fe/pages/learn/learn.js @@ -34,9 +34,6 @@ Page({ closeSheet() { this.setData({ sheetShow: false }); }, - onFab() { - this.setData({ sheetType: 'ai', sheetShow: true }); - }, onShareAppMessage() { return { title: '新手养宠知识合集', path: '/pages/learn/learn' }; }, diff --git a/pets-fe/pages/learn/learn.json b/pets-fe/pages/learn/learn.json index 24ea6ea..fa34741 100644 --- a/pets-fe/pages/learn/learn.json +++ b/pets-fe/pages/learn/learn.json @@ -2,6 +2,7 @@ "usingComponents": { "nav-bar": "/components/nav-bar/nav-bar", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "fab": "/components/fab/fab", + "pt-icon": "/components/pt-icon/index" } } diff --git a/pets-fe/pages/learn/learn.wxml b/pets-fe/pages/learn/learn.wxml index bd4c476..767741a 100644 --- a/pets-fe/pages/learn/learn.wxml +++ b/pets-fe/pages/learn/learn.wxml @@ -1,18 +1,17 @@ - + - 新手养宠知识 - 新手养宠知识,看完顺手生成你的计划。 + 看完顺手生成你的计划。 + 还没有文章 - {{item.icon}} + {{item.title}}{{item.desc}} - diff --git a/pets-fe/pages/learn/learn.wxss b/pets-fe/pages/learn/learn.wxss deleted file mode 100644 index b646141..0000000 --- a/pets-fe/pages/learn/learn.wxss +++ /dev/null @@ -1,9 +0,0 @@ -.page-body{padding:8rpx 40rpx calc(60rpx + env(safe-area-inset-bottom))} -.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800} -.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx} -.ac-b{font-size:30rpx;font-weight:700} -.ac-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx} - -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} diff --git a/pets-fe/pages/mine/mine.js b/pets-fe/pages/mine/mine.js new file mode 100644 index 0000000..1bab4e1 --- /dev/null +++ b/pets-fe/pages/mine/mine.js @@ -0,0 +1,81 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const { toastErr } = require('../../utils/ui.js'); +const { syncTabBar } = require('../../utils/tabbar.js'); + +Page({ + data: { user: {}, pets: [], card: null, sheetShow: false, sheetType: '' }, + onLoad() { + this._unsub = store.subscribe(() => this.applyUser()); + }, + onUnload() { + if (this._unsub) this._unsub(); + }, + onShow() { + syncTabBar(this); + store + .ready() + .then(() => { + this.applyUser(); + this.loadCard(); + }) + .catch((e) => toastErr(e)); + }, + applyUser() { + this.setData({ pets: store.getPets(), user: store.getUser() || {} }); + }, + // 头部数据走 /users/:id/profile,和别人看我时是同一个接口—— + // 换成 /user/summary 的话两边字段会对不上,装扮效果也预览不到 + loadCard() { + const uid = (this.data.user && this.data.user.id) || ''; + if (!uid) return; + api + .userCard(uid) + .then((card) => this.setData({ card })) + .catch(() => {}); + }, + // 看自己的公开主页,和别人看到的是同一个页面、同一套渲染 + previewPublic() { + const uid = this.data.user && this.data.user.id; + if (uid) wx.navigateTo({ url: `/pages/user/user?id=${uid}` }); + }, + goSettings() { + wx.navigateTo({ url: '/pages/settings/settings' }); + }, + goRelations(e) { + const uid = (this.data.user && this.data.user.id) || ''; + if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' }); + wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.detail.kind}` }); + }, + // 宠物档案:当前这只的编辑页 + goPetForm() { + const id = store.currentPetId(); + if (!id) return wx.navigateTo({ url: '/pages/petform/petform' }); + wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); + }, + goDecorate() { + wx.navigateTo({ url: '/pages/decorate/decorate' }); + }, + goPlan() { + wx.navigateTo({ url: '/pages/plan/plan' }); + }, + // 列表行传 dataset,宠物名片墙传 detail,两边都进这里 + onPickPet(e) { + const id = (e.detail && e.detail.id) || e.currentTarget.dataset.id; + if (!id) return; + store.switchPet(id); + wx.switchTab({ url: '/pages/home/home' }); + }, + openSheet(e) { + this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); + }, + closeSheet() { + this.setData({ sheetShow: false }); + }, + onAddPet() { + wx.navigateTo({ url: '/pages/petform/petform' }); + }, + onShareAppMessage() { + return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' }; + }, +}); diff --git a/pets-fe/pages/mine/mine.json b/pets-fe/pages/mine/mine.json new file mode 100644 index 0000000..1f40594 --- /dev/null +++ b/pets-fe/pages/mine/mine.json @@ -0,0 +1,9 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "bottom-sheet": "/components/bottom-sheet/bottom-sheet", + "fab": "/components/fab/fab", + "pt-icon": "/components/pt-icon/index", + "profile-head": "/components/profile-head/index" + } +} diff --git a/pets-fe/pages/mine/mine.wxml b/pets-fe/pages/mine/mine.wxml new file mode 100644 index 0000000..ea1d2ad --- /dev/null +++ b/pets-fe/pages/mine/mine.wxml @@ -0,0 +1,42 @@ + + + + + + + + + 看看别人眼里的我 + + + + + + 我的毛孩子 + + + + + {{item.emoji}} + + + {{item.name}} + {{item.age}}|{{item.weight}}|{{item.stage}} + + + + 还没有毛孩子,先建一份档案 + + + + + + + 设置 + + + + + + diff --git a/pets-fe/pages/mine/mine.wxss b/pets-fe/pages/mine/mine.wxss new file mode 100644 index 0000000..f6c2de0 --- /dev/null +++ b/pets-fe/pages/mine/mine.wxss @@ -0,0 +1,12 @@ + +.pet-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)} +.pet-row:last-child{border-bottom:0} +.pet-emoji{overflow:hidden; + width:76rpx;height:76rpx;border-radius:var(--r-sm);flex:none; + display:flex;align-items:center;justify-content:center; + background:var(--primary-soft);font-size:40rpx; +} +.pet-info{flex:1;min-width:0} + +.me-preview{margin-bottom:var(--sp-5)} +.pet-emoji-img{width:100%;height:100%;display:block} diff --git a/pets-fe/pages/onboarding/onboarding.js b/pets-fe/pages/onboarding/onboarding.js index 705b11a..593c988 100644 --- a/pets-fe/pages/onboarding/onboarding.js +++ b/pets-fe/pages/onboarding/onboarding.js @@ -3,6 +3,36 @@ const api = require('../../utils/api.js'); const GENDERS = ['男孩', '女孩', '不确定']; +// 与后端 service.AgeStages 一致:「刚到家」不是年龄阶段,它由到家日期单独决定 +const STAGE_OPTIONS = [ + { key: '幼年期', desc: '疫苗针次、驱虫、体重增长、社会化训练' }, + { key: '成年期', desc: '体重控制、口腔、日常健康管理' }, + { key: '老年期', desc: '慢病监测、半年体检、用药与护理' }, +]; + +function today() { + const d = new Date(); + const p = (n) => (n < 10 ? '0' + n : '' + n); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); +} + +function monthsSince(dateStr) { + const b = new Date(dateStr); + const now = new Date(); + let m = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth()); + if (now.getDate() < b.getDate()) m--; + return m < 0 ? 0 : m; +} + +// 与后端 StageFromBirthday 同一套规则:12 月龄成年、7 岁进入老年 +function stageFromBirthday(birthday) { + if (!birthday) return ''; + const m = monthsSince(birthday); + if (m < 12) return '幼年期'; + if (m >= 84) return '老年期'; + return '成年期'; +} + function ageFromBirthday(bdayStr) { if (!bdayStr) return ''; const b = new Date(bdayStr); @@ -14,6 +44,7 @@ function ageFromBirthday(bdayStr) { Page({ data: { + booting: true, // 冷启动先显示闪屏,静默登录 + 拉宠物期间不闪建档向导 step: 0, petType: '猫猫', petEmoji: '🐱', @@ -21,7 +52,11 @@ Page({ birthday: '', genderIdx: 0, weight: '', - stage: '幼年期', + arrivedAt: '', + today: '', + stage: '', + stageAuto: '', + stageOptions: STAGE_OPTIONS, goals: [ { label: '不知道每天该做什么', on: true }, { label: '疫苗驱虫怕忘记', on: true }, @@ -33,15 +68,20 @@ Page({ submitting: false, }, onLoad() { - // 已有宠物则直接进首页 + this.setData({ today: today(), arrivedAt: today() }); + // 闪屏期间就把登录 + 宠物拉完,保证进首页时数据已经到位。 + // 有宠物直接进首页;没有才落到建档向导。登录失败也别卡在闪屏, + // 放用户进向导(提交时会再 store.ready 重试) store .ready() .then(() => { if (store.getPets().length > 0) { wx.switchTab({ url: '/pages/home/home' }); + } else { + this.setData({ booting: false }); } }) - .catch(() => {}); + .catch(() => this.setData({ booting: false })); }, nextStep() { this.setData({ step: Math.min(this.data.step + 1, 5) }); @@ -52,7 +92,15 @@ Page({ }, onName(e) { this.setData({ petName: e.detail.value }); }, onWeight(e) { this.setData({ weight: e.detail.value }); }, - onBirthday(e) { this.setData({ birthday: e.detail.value }); }, + onBirthday(e) { + const birthday = e.detail.value; + const auto = stageFromBirthday(birthday); + const patch = { birthday, stageAuto: auto }; + // 用户手动选过就尊重他的选择,否则跟着生日走 + if (!this._stageTouched) patch.stage = auto; + this.setData(patch); + }, + onArrived(e) { this.setData({ arrivedAt: e.detail.value }); }, selectGender(e) { this.setData({ genderIdx: Number(e.currentTarget.dataset.index) }); }, // 先随便看看:不建数据,直接进首页浏览(无宠物时首页显示空态) skipBrowse() { wx.switchTab({ url: '/pages/home/home' }); }, @@ -61,10 +109,39 @@ Page({ wx.showToast({ title: '给它起个名字吧', icon: 'none' }); return; } + const w = parseFloat(this.data.weight); + // 体重会进趋势图和用药剂量参考,乱填不如不填 + if (this.data.weight && !(w > 0 && w < 120)) { + wx.showToast({ title: '体重看起来不太对', icon: 'none' }); + return; + } + if (!this.data.birthday) { + wx.showToast({ title: '生日决定疫苗和驱虫节奏,估个大概也行', icon: 'none' }); + return; + } this.nextStep(); }, chooseStage(e) { - this.setData({ stage: e.currentTarget.dataset.stage }); + const stage = e.currentTarget.dataset.stage; + const auto = this.data.stageAuto; + // 改成和生日矛盾的阶段是允许的(领养估的生日可能不准,大型犬发育也慢), + // 但要先说清楚代价:阶段决定疫苗针次怎么排,选错会漏针 + if (auto && stage !== auto) { + wx.showModal({ + title: '和生日对不上', + content: `按生日算它应该在「${auto}」。改成「${stage}」的话,疫苗针次和驱虫周期会按${stage}安排。确定要改吗?`, + confirmText: '确定改', + success: (res) => { + if (res.confirm) { + this._stageTouched = true; + this.setData({ stage }); + } + }, + }); + return; + } + this._stageTouched = true; + this.setData({ stage }); }, toggleGoal(e) { const i = e.currentTarget.dataset.index; @@ -80,8 +157,9 @@ Page({ type: this.data.petType, gender: GENDERS[this.data.genderIdx], birthday: this.data.birthday, + arrived_at: this.data.arrivedAt, weight: this.data.weight, - stage: this.data.stage, + stage: this.data.stage || this.data.stageAuto, age: ageFromBirthday(this.data.birthday), goals: this.data.goals.filter((g) => g.on).map((g) => g.label), }; diff --git a/pets-fe/pages/onboarding/onboarding.json b/pets-fe/pages/onboarding/onboarding.json index c55f727..c58f7d1 100644 --- a/pets-fe/pages/onboarding/onboarding.json +++ b/pets-fe/pages/onboarding/onboarding.json @@ -1,6 +1,7 @@ { "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar" + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" }, "navigationBarTitleText": "肉垫计划" } diff --git a/pets-fe/pages/onboarding/onboarding.wxml b/pets-fe/pages/onboarding/onboarding.wxml index 35777f8..d9e9f93 100644 --- a/pets-fe/pages/onboarding/onboarding.wxml +++ b/pets-fe/pages/onboarding/onboarding.wxml @@ -1,3 +1,17 @@ + + + + + + + + + + 肉垫计划 + 正在准备今天的养护计划… + + + @@ -5,14 +19,20 @@ - + 肉垫计划 - 新手养宠的 AI 日历、健康记录和成长报告。先生成计划,再每天照着做。 + 新手养宠的养护日历、健康记录和成长报告。先生成计划,再每天照着做。 30天新手计划 - 8类快速记录 - AI观察建议 + 24类快速记录 + 成长报告卡片 先随便看看 @@ -24,10 +44,10 @@ 目前支持猫和狗,选一个开始。 - 🐱猫猫幼猫、成年猫、老年猫 + 猫猫幼猫、成年猫、老年猫 - 🐶狗狗幼犬、成年犬、老年犬 + 狗狗幼犬、成年犬、老年犬 @@ -40,10 +60,15 @@ - - - {{birthday || '选择日期'}} + + + {{birthday || '选择日期(不确定可以估个大概)'}} + + + {{arrivedAt || '选择日期'}} + + 刚到家不满 30 天的话,会额外安排一组安置期任务 男孩 女孩 @@ -58,16 +83,16 @@ 它现在处于哪个阶段? - 阶段会决定今日任务、提醒和 AI 建议。 + 阶段决定疫苗节奏、驱虫周期和每天要做的事,选错整套计划都会偏。 + + 已按生日自动判断为「{{stageAuto}}」,不对可以改 + - - 幼年期疫苗、驱虫、体重增长、行为训练 - - 刚到家 0-30 天适应环境、饮食稳定、基础照护 - - 成年期体重、饮食、日常健康管理 - - 老年期慢病、体检、用药和护理 + + {{item.key}}按生日推荐 + {{item.desc}} + @@ -75,10 +100,10 @@ 你最想解决什么? - 多选,我们会据此为你定制计划。 + 勾上的会变成真的提醒和任务,没勾的不会打扰你。 - {{item.on ? '✓' : ''}}{{item.label}} + {{item.label}} @@ -89,13 +114,14 @@ 正在生成专属养宠计划 正在根据它的信息生成专属计划… - 已识别:{{stage}} - 已生成:30 天新手计划 - 已安排:疫苗 / 驱虫提醒 - 已开启:体重、便便、饮食记录 - 已准备:首张成长报告卡片 + 已识别:{{stage}} + 已生成:30 天新手计划 + 已安排:疫苗 / 驱虫提醒 + 已开启:体重、便便、饮食记录 + 已准备:首张成长报告卡片 + diff --git a/pets-fe/pages/onboarding/onboarding.wxss b/pets-fe/pages/onboarding/onboarding.wxss index efdbd3d..907135d 100644 --- a/pets-fe/pages/onboarding/onboarding.wxss +++ b/pets-fe/pages/onboarding/onboarding.wxss @@ -1,55 +1,103 @@ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} .onboard-page{ - padding:0 40rpx 56rpx; + padding:0 var(--pad-x) 56rpx; min-height:100%; background: radial-gradient(circle at top right, rgba(245,169,71,.22), transparent 45%), var(--bg); } .step{animation:fade .24s ease} -.skip-link{text-align:center;margin-top:28rpx;color:var(--muted);font-size:28rpx;padding:12rpx} +.skip-link{text-align:center;margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-md);padding:var(--sp-2)} @keyframes fade{from{opacity:0;transform:translateY(16rpx)}to{opacity:1;transform:none}} -.hero{text-align:center;padding:60rpx 0 32rpx} +/* 冷启动闪屏 */ +.boot{ + position:fixed;inset:0;z-index:200; + display:flex;flex-direction:column;align-items:center;justify-content:center; + background: + radial-gradient(circle at 50% 32%, rgba(253,179,92,.28), transparent 55%), + var(--bg); +} +.boot-badge{ + width:184rpx;height:184rpx;border-radius:44rpx; + background:linear-gradient(150deg,#FFB865,#F5943E); + display:flex;align-items:center;justify-content:center; + box-shadow:0 18rpx 42rpx rgba(214,138,52,.4); + animation:boot-pop .5s ease both, boot-breathe 2.4s ease-in-out .5s infinite; +} +.boot-name{margin-top:var(--sp-5);font-size:var(--fs-2xl);font-weight:var(--fw-b);letter-spacing:-1rpx} +.boot-tip{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md)} +@keyframes boot-pop{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}} +@keyframes boot-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-10rpx)}} + +.hero{text-align:center;padding:60rpx 0 var(--sp-5)} .pet-logo{ - width:192rpx;height:192rpx;margin:0 auto 36rpx;border-radius:60rpx; - background:linear-gradient(135deg,#FFE0AB,#FDB35C); - display:flex;align-items:center;justify-content:center;font-size:96rpx; - box-shadow:0 32rpx 72rpx rgba(245,169,71,.26); + width:176rpx;height:176rpx;margin:0 auto var(--sp-5);border-radius:var(--r-lg); + background:linear-gradient(135deg,#FFB865,#F5943E); + display:flex;align-items:center;justify-content:center; + box-shadow:var(--sd-2); } -.hero-h1{font-size:64rpx;letter-spacing:-1.2rpx;font-weight:800} -.hero-sub{margin-top:20rpx;line-height:1.65;color:var(--muted);font-size:30rpx} -.proof-row{display:grid;grid-template-columns:repeat(3,1fr);gap:16rpx;margin:40rpx 0} -.proof{background:#fff;border-radius:36rpx;padding:24rpx 16rpx;text-align:center;box-shadow:var(--shadow)} -.proof-b{display:block;font-size:32rpx;font-weight:800} -.proof-s{color:var(--muted);font-size:22rpx} +/* 肉垫(呼应「肉垫计划」)——奶白掌垫 + 四趾垫,闪屏和欢迎屏共用 */ +.paw{position:relative;width:100rpx;height:92rpx} +.paw .pad{ + position:absolute;left:50%;bottom:0;transform:translateX(-50%); + width:66rpx;height:54rpx;background:#FFF7EC; + border-radius:48% 48% 44% 44%/62% 62% 40% 40%; +} +.paw .bean{position:absolute;background:#FFF7EC;border-radius:50%} +.paw .bean-1{width:22rpx;height:30rpx;left:0;top:30rpx;transform:rotate(-20deg)} +.paw .bean-2{width:24rpx;height:33rpx;left:27rpx;top:6rpx;transform:rotate(-7deg)} +.paw .bean-3{width:24rpx;height:33rpx;right:27rpx;top:6rpx;transform:rotate(7deg)} +.paw .bean-4{width:22rpx;height:30rpx;right:0;top:30rpx;transform:rotate(20deg)} +.hero-h1{font-size:var(--fs-2xl);letter-spacing:-1.2rpx;font-weight:var(--fw-b)} +.hero-sub{margin-top:var(--sp-3);line-height:1.65;color:var(--muted);font-size:var(--fs-md)} -.step-title{font-size:48rpx;letter-spacing:-.8rpx;line-height:1.24;margin:16rpx 0;font-weight:800} -.step-sub{color:var(--muted);line-height:1.6;font-size:28rpx;margin-bottom:32rpx} +.proof-row{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);margin:var(--sp-6) 0} +.proof{background:#fff;border-radius:var(--r-md);padding:var(--sp-4) var(--sp-3);text-align:center;box-shadow:var(--sd-1)} +.proof-b{display:block;font-size:var(--fs-lg);font-weight:var(--fw-b)} +.proof-s{color:var(--muted);font-size:var(--fs-cap)} -.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:24rpx;margin-bottom:36rpx} +.step-title{font-size:var(--fs-2xl);letter-spacing:-.8rpx;line-height:1.24;margin:var(--sp-3) 0;font-weight:var(--fw-b)} +.step-sub{color:var(--muted);line-height:1.6;font-size:var(--fs-md);margin-bottom:var(--sp-5)} + +.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4);margin-bottom:var(--sp-5)} .choice-card{ - background:#fff;border:4rpx solid transparent;border-radius:48rpx;min-height:264rpx; - padding:36rpx 28rpx;box-shadow:var(--shadow);transition:.18s ease; + background:#fff;border:4rpx solid transparent;border-radius:var(--r-lg);min-height:248rpx; + padding:var(--sp-5) var(--sp-4);box-shadow:var(--sd-1);transition:.18s ease; + display:flex;flex-direction:column;align-items:flex-start; } -.choice-card.selected{border-color:var(--primary);background:#FFF8EC} -.choice-card .animal{font-size:88rpx;margin-bottom:20rpx} -.choice-b{font-size:34rpx;font-weight:900} -.choice-s{display:block;color:var(--muted);font-size:24rpx;margin-top:12rpx} +.choice-card.selected{border-color:var(--primary);background:var(--primary-soft)} +.choice-card .animal{ + width:96rpx;height:96rpx;border-radius:var(--r-md);margin-bottom:var(--sp-3); + display:flex;align-items:center;justify-content:center; + background:var(--primary-soft);color:var(--primary-ink); +} +.choice-card.selected .animal{background:#fff} +.choice-b{font-size:var(--fs-lg);font-weight:var(--fw-b)} +.choice-s{display:block;color:var(--muted);font-size:var(--fs-sm);margin-top:var(--sp-2)} -.form-card{background:#fff;border-radius:48rpx;padding:32rpx;box-shadow:var(--shadow);margin-bottom:36rpx} +.form-card{background:#fff;border-radius:var(--r-lg);padding:var(--sp-5);box-shadow:var(--sd-1);margin-bottom:var(--sp-5)} -.stage-list{display:flex;flex-direction:column;gap:22rpx;margin-bottom:36rpx} -.stage-card{background:#fff;border-radius:40rpx;border:4rpx solid transparent;padding:30rpx;box-shadow:var(--shadow)} +.stage-list{display:flex;flex-direction:column;gap:var(--sp-3);margin-bottom:var(--sp-5)} +.stage-card{background:#fff;border-radius:var(--r-md);border:4rpx solid transparent;padding:var(--sp-4);box-shadow:var(--sd-1)} .stage-card.selected{border-color:var(--green);background:var(--green-soft)} -.stage-b{font-size:32rpx;font-weight:900} -.stage-p{color:var(--muted);font-size:24rpx;margin-top:10rpx} +.stage-b{font-size:var(--fs-lg);font-weight:var(--fw-b)} +.stage-p{color:var(--muted);font-size:var(--fs-sm);margin-top:var(--sp-1)} -.gen-card{margin-top:32rpx;background:#fff;border-radius:56rpx;padding:48rpx;box-shadow:var(--shadow2)} -.gen-line{display:flex;align-items:center;gap:20rpx;padding:20rpx 0;color:#5F554D;font-size:28rpx} +.gen-card{margin-top:var(--sp-5);background:#fff;border-radius:var(--r-lg);padding:var(--sp-6);box-shadow:var(--sd-2)} +.gen-line{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;color:var(--text-2);font-size:var(--fs-md)} .tick{ - width:48rpx;height:48rpx;border-radius:50%;background:var(--green);color:#fff; - display:flex;align-items:center;justify-content:center;font-size:28rpx;font-weight:900;flex:none; + width:44rpx;height:44rpx;border-radius:50%;background:var(--green);color:#fff; + display:flex;align-items:center;justify-content:center;flex:none; +} + +.field-tip{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-cap);line-height:1.5} +.stage-auto{ + display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-3); + padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md); + background:var(--green-soft);color:var(--green-ink);font-size:var(--fs-sm); +} +.stage-hint{ + margin-left:var(--sp-2);padding:2rpx var(--sp-2);border-radius:var(--r-full); + background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);font-weight:var(--fw); } diff --git a/pets-fe/pages/petform/petform.js b/pets-fe/pages/petform/petform.js new file mode 100644 index 0000000..0137fc7 --- /dev/null +++ b/pets-fe/pages/petform/petform.js @@ -0,0 +1,291 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const upload = require('../../utils/upload.js'); +const { toastErr } = require('../../utils/ui.js'); + +const GENDERS = ['男孩', '女孩', '不确定']; +// 阶段列表和划分标准都从后端来。原来这里写死 4 个,后端细分成 5 段 +// (而且狗还按体型走不同阈值)之后就对不上了 —— 而且不会报错,只是少两个选项。 +// 「刚到家 0-30 天」在后端列表的第一个,它和年龄正交,是叠加层 + +function pad2(n) { + return n < 10 ? '0' + n : '' + n; +} +function ymd(d) { + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} +function monthsSince(bday) { + const b = new Date(String(bday).slice(0, 10) + 'T00:00:00'); + if (isNaN(b.getTime())) return -1; + const n = new Date(); + const m = (n.getFullYear() - b.getFullYear()) * 12 + (n.getMonth() - b.getMonth()); + return m < 0 ? 0 : m; +} +function ageLabel(bday) { + const m = monthsSince(bday); + if (m < 0) return ''; + return m < 24 ? m + '个月' : Math.floor(m / 12) + '岁'; +} +// 和后端 service.StageFromBirthday 一套口径:<12 月幼年,>=7 年老年,其余成年。 +// 前端算是为了在用户填完生日时就给出建议值,最终仍以用户选的为准 +function stageFromBirthday(bday) { + const m = monthsSince(bday); + if (m < 0) return ''; + if (m < 12) return '幼年期'; + if (m >= 84) return '老年期'; + return '成年期'; +} + +Page({ + data: { + editing: false, + petId: '', + step: 0, + steps: ['名字', '品种', '资料', '方案'], + genders: GENDERS, + stages: [], + stageRules: [], + stageBasis: '', + sizeLabel: '', + today: '', + stageAuto: '', + form: { + name: '', avatar_file_id: '', avatar_url: '', + species: 'cat', breed: '', + gender: '不确定', birthday: '', arrived_at: '', + weight: '', color: '', stage: '', + // 第四步挑的方案。'' 是「先空着自己排」,不是「没选」—— + // 所以初始值给 null,用来区分「还没走到第四步」 + template_id: null, + }, + tplOptions: [], + breedShow: false, + breedGroups: [], + breedAnchor: '', + saving: false, + }, + onLoad(q) { + this.setData({ today: ymd(new Date()) }); + this.loadStages(); + const id = (q && q.id) || ''; + if (!id) return; + // 编辑:不分步,一屏全放开——用户是来改某一项的,不该被按顺序走一遍 + store + .ready() + .then(() => { + const p = (store.getPets() || []).find((x) => x.id === id) || store.getPet() || {}; + this.setData({ + editing: true, + petId: id, + form: { + name: p.name || '', + avatar_file_id: '', + avatar_url: p.avatar_url || '', + species: p.type === '狗狗' ? 'dog' : 'cat', + breed: p.breed || '', + gender: GENDERS.indexOf(p.gender) >= 0 ? p.gender : '不确定', + birthday: p.birthday ? String(p.birthday).slice(0, 10) : '', + arrived_at: p.arrived_at ? String(p.arrived_at).slice(0, 10) : '', + weight: (p.weight || '').replace('kg', ''), + color: p.color || '', + stage: p.stage || '', + }, + }); + }) + .catch((e) => toastErr(e)); + }, + + onName(e) { + this.setData({ 'form.name': e.detail.value }); + }, + pickAvatar() { + upload + .chooseAndUploadImage() + .then((f) => this.setData({ 'form.avatar_file_id': f.id, 'form.avatar_url': f.url })) + .catch((e) => { + if (e && e.canceled) return; + toastErr(e, '上传失败'); + }); + }, + pickSpecies(e) { + const sp = e.currentTarget.dataset.sp; + if (sp === this.data.form.species) return; + // 换了物种,原来选的品种一定不对了 + this.setData({ 'form.species': sp, 'form.breed': '', breedGroups: [] }); + this.loadStages(); + }, + pickGender(e) { + this.setData({ 'form.gender': e.currentTarget.dataset.v }); + }, + pickStage(e) { + const v = e.currentTarget.dataset.v; + this.setData({ 'form.stage': v, stageBasis: this.basisOf(v) }); + }, + basisOf(stage) { + const r = (this.data.stageRules || []).find((x) => x.stage === stage); + return r ? r.range + ' · ' + r.basis : ''; + }, + // 阶段的划分标准要按物种和体型取:同样 10 个月,吉娃娃已经是青年期, + // 大丹犬还在幼年期。体型从选中品种的 size_class 来 + loadStages() { + const f = this.data.form; + let size = ''; + (this.data.breedGroups || []).forEach((g) => + g.items.forEach((b) => { + if (b.name === f.breed) size = b.size_class || ''; + }), + ); + api + .lifeStages(f.species, size) + .then((d) => { + const rules = (d && d.stages) || []; + this.setData({ + stageRules: rules, + // 阶段选项 = 「刚到家」+ 五个年龄段。刚到家是叠加层,后端的 + // CareStages 里也在第一个,这里保持一致 + stages: ['刚到家 0-30 天'].concat(rules.map((r) => r.stage)), + sizeLabel: (d && d.sizes && d.sizes[size]) || '', + stageBasis: this.basisOf(f.stage), + }); + }) + .catch(() => {}); + }, + onBirthday(e) { + const v = e.detail.value; + const auto = stageFromBirthday(v); + const patch = { 'form.birthday': v, stageAuto: auto }; + // 用户还没手选过阶段就跟着生日走;选过就不动他的选择 + if (auto && !this.data.form.stage) { + patch['form.stage'] = auto; + patch.stageBasis = this.basisOf(auto); + } + this.setData(patch); + }, + onArrived(e) { + this.setData({ 'form.arrived_at': e.detail.value }); + }, + onWeight(e) { + this.setData({ 'form.weight': e.detail.value }); + }, + onColor(e) { + this.setData({ 'form.color': e.detail.value }); + }, + + // ── 品种选择器 ── + openBreed() { + this.setData({ breedShow: true }); + if (this.data.breedGroups.length) return; + api + .breeds(this.data.form.species) + .then((groups) => this.setData({ breedGroups: groups || [] })) + .catch((e) => toastErr(e)); + }, + closeBreed() { + this.setData({ breedShow: false }); + }, + noop() {}, + pickBreed(e) { + this.setData({ 'form.breed': e.currentTarget.dataset.name, breedShow: false }); + this.loadStages(); // 换了品种可能换了体型,阶段阈值跟着变 + }, + clearBreed() { + // 「不确定」要留:领养的串串确实说不出品种,逼着选一个只会得到假数据 + this.setData({ 'form.breed': '', breedShow: false }); + }, + jumpInitial(e) { + this.setData({ breedAnchor: 'bi-' + e.currentTarget.dataset.i }); + }, + + loadTemplates() { + const f = this.data.form; + api + .careTemplates(f.species, f.stage) + .then((opts) => { + const list = opts || []; + this.setData({ + tplOptions: list, + // 默认选第一套:多数用户是新手,给一个合理默认比让他面对空白好。 + // 但「先空着」就在下面,一眼能看到,不是藏起来的 + 'form.template_id': list.length ? list[0].id : '', + }); + }) + .catch(() => this.setData({ tplOptions: [], 'form.template_id': '' })); + }, + pickTemplate(e) { + this.setData({ 'form.template_id': e.currentTarget.dataset.id }); + }, + + // ── 分步 ── + prev() { + if (this.data.step > 0) this.setData({ step: this.data.step - 1 }); + }, + // 每一步只校验这一步的东西。三步全填完才校验的话,用户在第三步才被告知 + // 第一步没填名字,还得翻回去 + stepError() { + const f = this.data.form; + if (this.data.step === 0 && !f.name.trim()) return '先给它起个名字'; + if (this.data.step === 1 && !f.species) return '选一下是猫还是狗'; + if (this.data.step === 2 && !f.stage) return '选一下现在是哪个阶段 —— 方案是按阶段配的'; + return ''; + }, + next() { + if (this.data.saving) return; + if (!this.data.editing) { + const err = this.stepError(); + if (err) return wx.showToast({ title: err, icon: 'none' }); + if (this.data.step < 3) { + const next = this.data.step + 1; + this.setData({ step: next }); + if (next === 3) this.loadTemplates(); // 方案按阶段取,得等第三步填完 + return; + } + } else if (!this.data.form.name.trim()) { + return wx.showToast({ title: '名字不能空', icon: 'none' }); + } + this.submit(); + }, + body() { + const f = this.data.form; + const isDog = f.species === 'dog'; + const body = { + name: f.name.trim(), + type: isDog ? '狗狗' : '猫猫', + // emoji 是老字段,列表和海报还在用它兜底;有头像时它不显示 + emoji: isDog ? '🐶' : '🐱', + gender: f.gender, + birthday: f.birthday, + arrived_at: f.arrived_at, + weight: f.weight, + color: f.color.trim(), + breed: f.breed, + stage: f.stage || stageFromBirthday(f.birthday) || '成年期', + age: ageLabel(f.birthday), + }; + // 没重新传头像就不带这个字段,别把已有头像覆盖成空 + if (f.avatar_file_id) body.avatar_file_id = f.avatar_file_id; + return body; + }, + submit() { + this.setData({ saving: true }); + const b = this.body(); + const tplID = this.data.form.template_id; + const req = this.data.editing ? api.updatePet(this.data.petId, b) : api.createPet(b); + req + // 套方案要等档案建出来才有 pet id。套用失败不算建档失败—— + // 档案已经建好了,方案之后在计划页还能套,不该把用户退回表单 + .then((pet) => { + if (this.data.editing || !tplID || !pet || !pet.id) return; + return api.applyTemplate(pet.id, tplID, 0).catch(() => {}); + }) + .then(() => store.loadPets()) + .then(() => { + this.setData({ saving: false }); + wx.showToast({ title: this.data.editing ? '已保存' : '建好啦', icon: 'success' }); + setTimeout(() => wx.navigateBack(), 700); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, +}); diff --git a/pets-fe/pages/petform/petform.json b/pets-fe/pages/petform/petform.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/petform/petform.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/petform/petform.wxml b/pets-fe/pages/petform/petform.wxml new file mode 100644 index 0000000..cd65a06 --- /dev/null +++ b/pets-fe/pages/petform/petform.wxml @@ -0,0 +1,161 @@ + + + + + + {{index < step ? '✓' : index + 1}} + {{item}} + + + + + + + + + + 先给它起个名字 + + + + + + {{form.avatar_url ? '换一张' : '传张照片'}} + + + + + + + + + + 它是猫还是狗? + + + 猫猫 + + + 狗狗 + + + + + {{form.breed || '选一个品种'}} + + + + + + + + + 再补几项,之后都能改 + + {{item}} + + + + + + {{form.birthday || '不知道可以先跳过'}} + + + + {{form.arrived_at || '哪天接回家的'}} + + + + + + + + {{item}} + + + {{stageBasis}} + {{sizeLabel}}——阶段阈值按体型算 + 按生日推算是「{{stageAuto}}」,可以手改 + + + + + + + + 要不要套一套养护方案? + 按 {{form.stage}} 配的,套用后会排进本月的计划。之后随时能改、能删。 + + + + {{item.name}} + + + {{item.summary}} + + 每天 {{item.daily}} 项 + 每周 {{item.weekly}} 项 + 每月 {{item.monthly}} 项 + 一次性 {{item.once}} 项 + + + + + + + 先空着,我自己排 + + + 之后在「管理计划」里随时能套用 + + + 这个阶段还没有配方案,建完档可以自己排 + 疫苗和驱虫提醒不管选哪个都会建 —— 漏针是真会出事的 + + + + + + + + 上一步 + + {{saving ? '保存中…' : (editing ? '保存' : (step < 3 ? '下一步' : '完成建档'))}} + + + + + + + + 选择品种 + 不确定 + + + + + {{item.initial}} + {{b.name}} + + 还没有配置品种 + + + {{item.initial}} + + + + diff --git a/pets-fe/pages/petform/petform.wxss b/pets-fe/pages/petform/petform.wxss new file mode 100644 index 0000000..b486f03 --- /dev/null +++ b/pets-fe/pages/petform/petform.wxss @@ -0,0 +1,108 @@ +.pf-body{padding-bottom:calc(200rpx + env(safe-area-inset-bottom))} + +/* 三步进度条。不用 seg-tabs:那是「可以随意切」的语义, + 这里是有顺序的,点第三步跳过前两步会拿到一份没名字的档案 */ +.pf-steps{ + flex:none;display:flex;align-items:flex-start;justify-content:center; + gap:var(--sp-6);padding:var(--sp-3) var(--pad-x) var(--sp-4); +} +.pf-step{display:flex;flex-direction:column;align-items:center;gap:6rpx} +.pf-dot{ + width:48rpx;height:48rpx;border-radius:50%; + display:flex;align-items:center;justify-content:center; + background:#fff;border:1rpx solid var(--line); + color:var(--muted2);font-size:var(--fs-sm);font-weight:var(--fw-b); +} +.pf-slabel{font-size:var(--fs-cap);color:var(--muted2)} +.pf-step.on .pf-dot{background:var(--primary);border-color:var(--primary);color:#fff} +.pf-step.on .pf-slabel{color:var(--primary-dark);font-weight:var(--fw-b)} + +.pf-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4)} + +/* 头像 */ +.pf-av-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-5)} +.pf-av{ + width:176rpx;height:176rpx;border-radius:50%;overflow:hidden; + background:var(--primary-soft);color:var(--primary-dark); + display:flex;align-items:center;justify-content:center; +} +.pf-av-img{width:100%;height:100%} +.pf-av-tip{font-size:var(--fs-cap);color:var(--primary-dark)} + +/* 猫 / 狗 */ +.pf-species{display:flex;gap:var(--sp-3);margin-bottom:var(--sp-4)} +.pf-sp{ + flex:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-2); + padding:var(--sp-5) 0;border-radius:var(--r-md); + background:var(--surface-2);border:2rpx solid var(--line); + color:var(--muted);font-size:var(--fs-md); +} +.pf-sp.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)} +.pf-empty{color:var(--muted2)} + +/* 底部按钮 */ +.pf-foot{ + position:fixed;left:0;right:0;bottom:0;z-index:50;display:flex;gap:var(--sp-3); + padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom)); + background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06); +} +.pf-back{flex:none;width:200rpx} +.pf-next{flex:1} +.pf-busy{opacity:.6} + +/* 品种选择器 */ +.pf-mask{position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45);display:flex;align-items:flex-end} +.pf-sheet{ + width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0; + padding:var(--sp-5) 0 calc(var(--sp-3) + env(safe-area-inset-bottom)); + box-shadow:var(--sd-3); +} +.pf-sheet-h{ + display:flex;align-items:center;justify-content:space-between; + padding:0 var(--pad-x) var(--sp-4); + font-size:var(--fs-lg);font-weight:var(--fw-b); +} +.pf-breed-wrap{display:flex;height:60vh} +.pf-breed-list{flex:1;min-width:0;padding-left:var(--pad-x)} +/* 首字母吸顶:滚长列表时得知道现在在哪一段 */ +.pf-bi{ + position:sticky;top:0;z-index:1; + background:var(--bg2);color:var(--primary-dark); + font-size:var(--fs-sm);font-weight:var(--fw-b);padding:var(--sp-2) 0; +} +.pf-brow{ + padding:var(--sp-3) 0;font-size:var(--fs-md);color:var(--text); + border-bottom:1rpx solid var(--line); +} +.pf-brow.on{color:var(--primary-dark);font-weight:var(--fw-b)} +/* 右侧 A-Z 索引 */ +.pf-index{ + flex:none;width:56rpx;display:flex;flex-direction:column; + align-items:center;justify-content:center;gap:2rpx; +} +.pf-ichar{ + font-size:var(--fs-cap);color:var(--muted); + padding:2rpx 8rpx;line-height:1.4; +} + +/* 阶段的划分依据。摆出来而不是让用户猜「幼年期」是几个月到几个月 */ +.pf-basis{ + margin-top:var(--sp-2);background:var(--primary-soft);color:var(--primary-ink); + border-radius:var(--r-sm);padding:var(--sp-2) var(--sp-3); + font-size:var(--fs-cap);line-height:1.6; +} + +/* 第四步:方案卡 */ +.pf-sub{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:calc(-1 * var(--sp-2)) 0 var(--sp-4)} +.pf-tpl{ + border:2rpx solid var(--line);border-radius:var(--r-md); + padding:var(--sp-4);margin-bottom:var(--sp-3);background:var(--surface-2); +} +.pf-tpl.on{border-color:var(--primary);background:var(--primary-soft)} +.pf-tpl-top{display:flex;align-items:center;gap:var(--sp-2)} +.pf-tpl-name{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.pf-tpl-tick{flex:none;color:var(--primary-dark);display:flex;align-items:center} +.pf-tpl-sum{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} +.pf-tpl-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-top:var(--sp-2); + font-size:var(--fs-cap);color:var(--muted)} +.pf-tpl-note{margin-top:var(--sp-2)} diff --git a/pets-fe/pages/plan/plan.js b/pets-fe/pages/plan/plan.js index f92a611..ba450dd 100644 --- a/pets-fe/pages/plan/plan.js +++ b/pets-fe/pages/plan/plan.js @@ -1,178 +1,277 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); - -// "2026-07-04" → "7月4日" -function fmtMD(date) { - if (!date) return ''; - const p = date.split('-'); - if (p.length < 3) return ''; - return +p[1] + '月' + +p[2] + '日'; -} +const { toastErr } = require('../../utils/ui.js'); function pad2(n) { return n < 10 ? '0' + n : '' + n; } +function ymd(d) { + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} +// "2026-07-04" → "7月4日" +function fmtMD(date) { + const p = (date || '').split('-'); + return p.length < 3 ? '' : +p[1] + '月' + +p[2] + '日'; +} +function parseYMD(s) { + const p = (s || '').split('-'); + return p.length < 3 ? null : new Date(+p[0], +p[1] - 1, +p[2]); +} -function buildCells(year, month, taskedDays, today) { +// 自然月网格。周一为一周起点,首尾补空格对齐星期。 +// 按月切而不是「从计划开始日起 30 天」:那样窗口跨两个月, +// 用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月。 +function buildCells(year, month, byDate, todayStr) { const first = new Date(year, month - 1, 1); - const lead = (first.getDay() + 6) % 7; // 周一为一周起点 - const daysInMonth = new Date(year, month, 0).getDate(); - const set = {}; - (taskedDays || []).forEach((d) => (set[d] = true)); + const lead = (first.getDay() + 6) % 7; + const total = new Date(year, month, 0).getDate(); const cells = []; - for (let i = 0; i < lead; i++) cells.push({ day: 0 }); - for (let d = 1; d <= daysInMonth; d++) { - cells.push({ day: d, date: year + '-' + pad2(month) + '-' + pad2(d), tasked: !!set[d], today: d === today }); + for (let i = 0; i < lead; i++) cells.push({ blank: true }); + for (let d = 1; d <= total; d++) { + const s = year + '-' + pad2(month) + '-' + pad2(d); + cells.push({ day: d, date: s, count: (byDate[s] || []).length, today: s === todayStr }); } + while (cells.length % 7) cells.push({ blank: true }); return cells; } +// off=0 本月,off=1 下月 +function monthOf(off) { + const n = new Date(); + const d = new Date(n.getFullYear(), n.getMonth() + off, 1); + return { year: d.getFullYear(), month: d.getMonth() + 1 }; +} +function monthEnd(off) { + const m = monthOf(off); + return m.year + '-' + pad2(m.month) + '-' + pad2(new Date(m.year, m.month, 0).getDate()); +} + Page({ data: { pet: {}, - planView: 'timeline', plan: null, - calCells: [], - calLabel: '', - calLoaded: false, - selectedCalDate: '', - dayDetail: null, - aiInput: '', - aiPlan: null, - aiBusy: false, + cells: [], + monthOff: 0, + monthLabel: '', + monthEndLabel: '', + nextCount: 0, + monthEmpty: false, + tplOptions: [], + tplShow: false, + pickStart: '', + pickEnd: '', + selDate: '', + selLabel: '', + dayTasks: [], + editShow: false, + editId: '', + form: { title: '', date: '', description: '', daily: false }, + saving: false, sheetShow: false, sheetType: '', }, onLoad() { this._unsub = store.subscribe((pet) => { - this.setData({ pet, calLoaded: false }); - if (!this._inited) return; // 首次由 onShow 加载 - this.loadTimeline(); - if (this.data.planView === 'calendar') this.loadCalendar(); + this.setData({ pet }); + if (this._inited) this.load(); }); }, onUnload() { if (this._unsub) this._unsub(); }, onShow() { - if (typeof this.getTabBar === 'function' && this.getTabBar()) { - this.getTabBar().setData({ selected: 1 }); - } store .ready() .then(() => { this._inited = true; this.setData({ pet: store.getPet() }); - this.loadTimeline(); + this.load(); }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); + .catch((e) => toastErr(e)); }, - loadTimeline() { + load() { const id = store.currentPetId(); if (!id) return; + this.loadTemplates(); api .getPlan(id) - .then((plan) => { - if (plan && plan.tasks) { - plan.tasks = plan.tasks.map((t) => ({ ...t, dateLabel: fmtMD(t.date) })); - } - this.setData({ plan }); - }) - .catch(() => this.setData({ plan: null })); + .then((plan) => this.apply(plan)) + .catch(() => this.setData({ plan: null, cells: [], dayTasks: [] })); }, - loadCalendar() { - const id = store.currentPetId(); - if (!id) return; - api - .planCalendar(id) - .then((res) => { - this.setData({ - calCells: buildCells(res.year, res.month, res.tasked_days, res.today), - calLabel: `${res.year} 年 ${res.month} 月`, - calLoaded: true, - }); - if (res.today) { - this.selectCalDay(res.year + '-' + pad2(res.month) + '-' + pad2(res.today)); - } - }) - .catch(() => {}); - }, - onTapCalDay(e) { - const date = e.currentTarget.dataset.date; - if (date) this.selectCalDay(date); - }, - selectCalDay(date) { - const id = store.currentPetId(); - if (!id) return; - this.setData({ selectedCalDate: date }); - api - .dayPlan(id, date) - .then((res) => { - const nodes = (res.plan_nodes || []).map((n) => ({ title: n.title, desc: n.description, done: n.done })); - const tasks = (res.tasks || []).map((t) => ({ title: t.title, desc: t.description, done: t.done })); - const reminders = (res.reminders || []).map((r) => ({ title: r.title })); - this.setData({ - dayDetail: { date, nodes, tasks, reminders, empty: !nodes.length && !tasks.length && !reminders.length }, - }); - }) - .catch(() => {}); - }, - switchPlanView(e) { - const view = e.currentTarget.dataset.view; - this.setData({ planView: view }); - if (view === 'calendar' && !this.data.calLoaded) this.loadCalendar(); - }, - // 计划明细勾选 - toggleTimelineTask(e) { - const id = e.currentTarget.dataset.id; - api.togglePlanTask(id).then(() => this.loadTimeline()).catch(() => {}); - }, - // AI 计划 - onAiInput(e) { - this.setData({ aiInput: e.detail.value }); - }, - genAIPlan() { - const id = store.currentPetId(); - const input = (this.data.aiInput || '').trim(); - if (!id || !input) { - wx.showToast({ title: '先描述一下情况', icon: 'none' }); - return; + apply(plan) { + const tasks = plan.tasks || []; + const byDate = {}; + tasks.forEach((t) => { + if (!t.date) return; + (byDate[t.date] = byDate[t.date] || []).push(t); + }); + this._byDate = byDate; + this.setData({ + plan: { ...plan, tasks }, + // 能排的区间:不早于计划开始日,不晚于下月月底。和后端 dayOffset 的校验一致 + pickStart: (plan.start_date || '').slice(0, 10), + pickEnd: monthEnd(1), + }); + this.renderMonth(this.data.monthOff); + if (this._pendingSel) { + // 刚加/改完,跳到那一天,不然用户看不到东西去哪了 + this.selectDay(this._pendingSel); + this._pendingSel = ''; } - this.setData({ aiBusy: true }); - wx.showLoading({ title: 'AI 生成中...' }); + }, + renderMonth(off) { + const m = monthOf(off); + const todayStr = ymd(new Date()); + const byDate = this._byDate || {}; + const prefix = m.year + '-' + pad2(m.month); + this.setData({ + monthOff: off, + monthLabel: m.year + ' 年 ' + m.month + ' 月', + monthEndLabel: fmtMD(monthEnd(off)), + cells: buildCells(m.year, m.month, byDate, todayStr), + nextCount: Object.keys(byDate).filter((d) => d.indexOf(monthOf(1).year + '-' + pad2(monthOf(1).month)) === 0) + .reduce((n, d) => n + byDate[d].length, 0), + // 当前看的这个月一条都没有 —— 空月份要给套方案的入口 + monthEmpty: !Object.keys(byDate).some((d) => d.indexOf(prefix) === 0), + }); + // 切到本月默认选今天;切到下月默认选 1 号(今天不在那个月里) + const inMonth = todayStr.indexOf(prefix) === 0; + this.selectDay(inMonth ? todayStr : prefix + '-01', todayStr); + }, + // 方案按宠物当前阶段取。宠物换了(pet-switch)阶段也可能不同,所以跟着 load 走 + loadTemplates() { + const pet = this.data.pet || {}; + if (!pet.stage) return; api - .createAIPlan(id, input) - .then((plan) => { + .careTemplates(pet.type === '狗狗' ? 'dog' : 'cat', pet.stage) + .then((opts) => this.setData({ tplOptions: opts || [] })) + .catch(() => this.setData({ tplOptions: [] })); + }, + openTpl() { + this.setData({ tplShow: true }); + }, + closeTpl() { + this.setData({ tplShow: false }); + }, + applyTpl(e) { + const id = e.currentTarget.dataset.id; + const petID = store.currentPetId(); + if (!petID || !id) return; + wx.showLoading({ title: '排进去…' }); + api + .applyTemplate(petID, id, this.data.monthOff) + .then((res) => { wx.hideLoading(); - this.setData({ aiPlan: plan, aiBusy: false }); + this.setData({ tplShow: false }); + const r = res || {}; + wx.showToast({ + title: r.plans > 0 ? '排了 ' + r.plans + ' 件事' : '这个月已经有这些安排了', + icon: 'none', + }); + this.load(); }) .catch((err) => { wx.hideLoading(); - this.setData({ aiBusy: false }); - wx.showToast({ title: err.message || '生成失败', icon: 'none' }); + toastErr(err, '套用失败'); }); }, - applyAIPlan() { - const p = this.data.aiPlan; - if (!p) return; + switchMonth(e) { + this.renderMonth(Number(e.currentTarget.dataset.off)); + }, + selectDay(date, todayStr) { + const t = todayStr || ymd(new Date()); + this.setData({ + selDate: date, + selLabel: date === t ? '今天的安排' : fmtMD(date) + '的安排', + dayTasks: (this._byDate || {})[date] || [], + }); + }, + onTapDay(e) { + const d = e.currentTarget.dataset.date; + if (d) this.selectDay(d); + }, + + toggleTask(e) { api - .applyAIPlan(p.id) - .then(() => wx.showToast({ title: '已加入我的计划', icon: 'success' })) - .catch((err) => wx.showToast({ title: err.message || '加入失败', icon: 'none' })); + .togglePlanTask(e.currentTarget.dataset.id) + .then(() => this.load()) + .catch((err) => toastErr(err, '操作失败')); }, - openSheet(e) { - const type = e.currentTarget.dataset.type; - if (!type) return; // 无关联动作的计划项只可勾选,不弹层 - this.setData({ sheetType: type, sheetShow: true }); + + // ── 加 / 改 ── + openAdd() { + this.setData({ + editShow: true, editId: '', + form: { title: '', date: this.data.selDate || ymd(new Date()), description: '', daily: false }, + }); }, + openEdit(e) { + const t = (this.data.dayTasks || []).find((x) => x.id === e.currentTarget.dataset.id); + if (!t) return; + this.setData({ + editShow: true, editId: t.id, + form: { title: t.title, date: t.date || this.data.selDate, description: t.description || '', daily: false }, + }); + }, + closeEdit() { + this.setData({ editShow: false }); + }, + noop() {}, + onFormTitle(e) { + this.setData({ 'form.title': e.detail.value }); + }, + onFormDate(e) { + this.setData({ 'form.date': e.detail.value }); + }, + onFormDesc(e) { + this.setData({ 'form.description': e.detail.value }); + }, + onFormDaily(e) { + this.setData({ 'form.daily': e.detail.value }); + }, + onSubmit() { + if (this.data.saving) return; + const f = this.data.form; + if (!(f.title || '').trim()) return wx.showToast({ title: '写一下做什么', icon: 'none' }); + const id = store.currentPetId(); + if (!id) return; + this.setData({ saving: true }); + const req = this.data.editId + ? api.updatePlanTask(this.data.editId, f) + : api.addPlanTask(id, f); + req + .then(() => { + this.setData({ saving: false, editShow: false }); + // 加完/改完要跳到那一天,不然用户改了日期却看不到东西去哪了 + this._pendingSel = f.date; + this.load(); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, + onDelete(e) { + const { id, title } = e.currentTarget.dataset; + wx.showModal({ + title: '删掉这件事?', + content: title || '', + confirmColor: '#EE7D73', + success: (r) => { + if (!r.confirm) return; + api + .deletePlanTask(id) + .then(() => this.load()) + .catch((err) => toastErr(err, '删除失败')); + }, + }); + }, + closeSheet() { this.setData({ sheetShow: false }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); - }, - onFab() { - this.setData({ sheetType: 'ai', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onShareAppMessage() { return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' }; diff --git a/pets-fe/pages/plan/plan.json b/pets-fe/pages/plan/plan.json index 075a8b2..58b7029 100644 --- a/pets-fe/pages/plan/plan.json +++ b/pets-fe/pages/plan/plan.json @@ -3,6 +3,8 @@ "nav-bar": "/components/nav-bar/nav-bar", "pet-switch": "/components/pet-switch/pet-switch", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "fab": "/components/fab/fab", + "pt-icon": "/components/pt-icon/index", + "seg-tabs": "/components/seg-tabs/index" } } diff --git a/pets-fe/pages/plan/plan.wxml b/pets-fe/pages/plan/plan.wxml index 001a365..30393e3 100644 --- a/pets-fe/pages/plan/plan.wxml +++ b/pets-fe/pages/plan/plan.wxml @@ -1,102 +1,117 @@ - + + + - - 养宠计划 - 按宠物查看路线图、日历与 AI 计划。 + + + 还没有毛孩子的档案 + 建好档会按它的年龄阶段自动排好 30 天计划和疫苗驱虫提醒。 + 建一份档案 - - - - 路线图 - 日历 - AI 计划 - - - - + + - {{pet.name}}的 30 天计划 - {{plan.completion_pct || 0}}% - - 完成度{{plan.completion_pct || 0}}% - - - - - {{item.day_label}}{{item.dateLabel}} - - {{item.done ? '✓' : ''}} - - {{item.title}}{{item.description}} - + {{monthLabel}} + + 本月 + 下月 - 暂无计划 - - - - - - - {{calLabel}}提醒 + + + + {{monthLabel}}还没有安排 + 套一套方案 + - {{item.day || ''}} + {{item.day || ''}} - - {{dayDetail.date}} 的安排 - - - 计划{{item.title}} - - - 任务{{item.title}} - - - 提醒{{item.title}} - - - 这天没有安排 - - - - - + - AI 计划模式Plan - 描述 {{pet.name}} 的近况(阶段、近期变化、担心的问题),AI 会提取关键信息并生成可执行计划。 - - - - - - 已提取信息 - - 阶段{{aiPlan.extracted.stage}} - 风险{{aiPlan.extracted.risk}} - 重点{{aiPlan.extracted.priority}} - 提醒{{aiPlan.extracted.reminder}} - - 生成的计划 - - - - {{item.day_label}}· {{item.title}}{{item.description}} + + {{selLabel}} + + 套方案 + + 加一件 - + + + + + + {{item.title}} + {{item.description}} + + + + + + 这天还没有安排,点右上角加一件 - + + - + + + + {{editId ? '改这件事' : '加一件事'}} + + + + + {{form.date}} + + 只能安排 {{pickStart}} 到 {{pickEnd}} 之间 + + + + + + + 应用到每天 + 从这天排到 {{monthEndLabel}},每天一条,可以分别打勾 + + + + {{saving ? '保存中…' : '保存'}} + + + + + + + 套一套方案到{{monthLabel}} + 按 {{pet.stage}} 配的。套用是「往里加」,不会覆盖你已经排好的。 + + {{item.name}} + {{item.summary}} + + 每天 {{item.daily}} 项 + 每周 {{item.weekly}} 项 + 每月 {{item.monthly}} 项 + 一次性 {{item.once}} 项 + + + 这个阶段还没有配方案 + + + diff --git a/pets-fe/pages/plan/plan.wxss b/pets-fe/pages/plan/plan.wxss index 41a9966..d320448 100644 --- a/pets-fe/pages/plan/plan.wxss +++ b/pets-fe/pages/plan/plan.wxss @@ -1,68 +1,70 @@ -.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))} -.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800} -.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx} -.tt-b{font-size:28rpx;font-weight:700} -.tt-p{font-size:24rpx;color:var(--muted);margin-top:8rpx;line-height:1.45} -.ac-b{font-size:30rpx;font-weight:700} -.ac-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx} - -.inline-tabs{display:flex;padding:8rpx;gap:8rpx;border-radius:32rpx;background:#EFE8DE;margin-bottom:28rpx} -.it-btn{ - flex:1;height:68rpx;line-height:68rpx;text-align:center;border-radius:24rpx; - color:var(--muted);font-weight:900;font-size:26rpx; -} -.it-btn.active{background:#fff;color:var(--primary-dark);box-shadow:0 12rpx 28rpx rgba(0,0,0,.06)} - -.timeline{padding-left:36rpx;position:relative} +/* 竖线时间轴 */ +.timeline{padding-left:var(--sp-6);position:relative} .timeline::before{content:"";position:absolute;left:12rpx;top:12rpx;bottom:16rpx;width:4rpx;background:#E8DED0} -.time-block{position:relative;margin-bottom:28rpx} -.time-label{font-weight:900;color:var(--primary-dark);font-size:26rpx;margin-bottom:16rpx;position:relative} -.time-date{margin-left:12rpx;font-weight:600;color:var(--muted);font-size:22rpx} +.time-block{position:relative;margin-bottom:var(--sp-4)} +.time-label{font-weight:var(--fw-b);color:var(--primary-dark);font-size:var(--fs-sm);margin-bottom:var(--sp-3);position:relative} +.time-date{margin-left:var(--sp-2);font-weight:var(--fw);color:var(--muted);font-size:var(--fs-cap)} .time-label::before{ content:"";position:absolute;left:-34rpx;top:8rpx;width:24rpx;height:24rpx;border-radius:50%; - background:var(--primary);border:6rpx solid #fff;box-shadow:0 0 0 4rpx #F5D5A8; + background:var(--primary);border:6rpx solid #fff;box-shadow:0 0 0 4rpx var(--primary-line); +} +.plan-task{ + background:#fff;border-radius:var(--r-md);padding:var(--sp-4);box-shadow:var(--sd-1); + border:1rpx solid transparent;display:flex;gap:var(--sp-3);align-items:flex-start; } -.plan-task{background:#fff;border-radius:36rpx;padding:26rpx;box-shadow:var(--shadow);border:1rpx solid transparent;display:flex;gap:18rpx;align-items:flex-start} .plan-task.done{opacity:.55} .pk-check{ width:44rpx;height:44rpx;border-radius:50%;border:4rpx solid #DED1C4;flex:none;margin-top:2rpx; - display:flex;align-items:center;justify-content:center;color:#fff;font-size:24rpx;font-weight:900; + display:flex;align-items:center;justify-content:center;color:#fff; } .plan-task.done .pk-check{background:var(--green);border-color:var(--green)} .pk-body{flex:1} -.pk-b{font-size:28rpx;font-weight:700} -.pk-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx} -.ai-plan-tip{color:var(--muted);font-size:26rpx;line-height:1.6;margin-bottom:16rpx} +.pk-b{font-size:var(--fs-md);font-weight:var(--fw-b)} +.pk-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:var(--sp-1)} +.ai-plan-tip{color:var(--muted);font-size:var(--fs-sm);line-height:1.6;margin-bottom:var(--sp-3)} -.calendar{background:#fff;border-radius:44rpx;padding:28rpx;box-shadow:var(--shadow)} -.cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:20rpx;font-weight:900;font-size:30rpx} -.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:12rpx;text-align:center} -.cal-w{font-size:22rpx;color:var(--muted);padding:8rpx 0} -.cal-cell{ - height:76rpx;border-radius:24rpx;display:flex;align-items:center;justify-content:center; - font-weight:800;font-size:26rpx;background:#FBFAF8;position:relative; +/* 日历 */ +/* AI 提取信息 */ +.extracted{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-3);margin:var(--sp-4) 0} +.extract{background:var(--surface-2);border-radius:var(--r-sm);padding:var(--sp-3);font-size:var(--fs-sm)} +.ex-b{display:block;font-size:var(--fs-sm);margin-bottom:6rpx;font-weight:var(--fw-b);color:var(--muted)} + +/* 计划节点右侧的删除键。计划是用户自己排的,就得能删 */ +.pk-del{flex:none;padding:0 var(--sp-1);color:var(--muted2);display:flex;align-items:center} + +/* 加/改计划的浮层。字段只有三个,再开一页太重 */ +.pe-mask{ + position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45); + display:flex;align-items:flex-end; } -.cal-cell.blank{background:transparent} -.cal-cell.today{background:#FFF3DF;color:var(--primary-dark)} -.cal-cell.tasked::after{content:"";position:absolute;bottom:10rpx;width:10rpx;height:10rpx;border-radius:50%;background:var(--green)} -.cal-cell.sel{background:var(--primary);color:#fff} -.cal-cell.sel.tasked::after{background:#fff} +.pe-sheet{ + width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0; + padding:var(--sp-5) var(--pad-x) calc(var(--sp-5) + env(safe-area-inset-bottom)); + box-shadow:var(--sd-3);max-height:82vh;overflow-y:auto; +} +.pe-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4);text-align:center} -.day-detail{margin-top:24rpx;border-top:1rpx solid var(--line);padding-top:24rpx} -.dd-title{font-weight:900;font-size:28rpx;margin-bottom:16rpx} -.dd-item{display:flex;align-items:center;gap:16rpx;padding:14rpx 0} -.dd-tag{flex:none;font-size:20rpx;font-weight:700;padding:2rpx 12rpx;border-radius:999rpx} -.dd-tag.plan{background:#FFF2DC;color:var(--primary-dark)} -.dd-tag.task{background:#E6F4EA;color:#2E7D5B} -.dd-tag.rem{background:#EAF0FF;color:#3B6BE5} -.dd-b{font-size:27rpx;color:var(--text)} -.dd-b.done{text-decoration:line-through;color:var(--muted)} -.dd-empty{padding:20rpx 0;color:var(--muted);font-size:26rpx;text-align:center} +/* 下月还空着时的提示。放在月历里而不是弹 toast——用户切到下月本来就是来排的 */ +.plan-tip{ + display:flex;align-items:center;gap:var(--sp-2); + background:var(--primary-soft);color:var(--primary-ink); + border-radius:var(--r-sm);padding:var(--sp-3); + font-size:var(--fs-sm);margin-bottom:var(--sp-3); +} -.extracted{display:grid;grid-template-columns:1fr 1fr;gap:16rpx;margin:24rpx 0} -.extract{background:#FBFAF8;border-radius:30rpx;padding:20rpx;font-size:24rpx} -.ex-b{display:block;font-size:26rpx;margin-bottom:6rpx;font-weight:700} +.pe-daily{display:flex;align-items:center;gap:var(--sp-4)} +.pe-daily-main{flex:1;min-width:0} -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} +/* 空月份的提示条:一行放不下「文案 + 按钮」,改成 flex */ +.plan-tip-b{flex:1;min-width:0} + +/* 套方案浮层 */ +.pt-sub{margin:calc(-1 * var(--sp-2)) 0 var(--sp-4);text-align:center} +.pt-item{ + border:2rpx solid var(--line);border-radius:var(--r-md); + padding:var(--sp-4);margin-bottom:var(--sp-3);background:#fff; +} +.pt-name{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.pt-sum{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} +.pt-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-top:var(--sp-2); + font-size:var(--fs-cap);color:var(--muted)} diff --git a/pets-fe/pages/profile/profile.wxml b/pets-fe/pages/profile/profile.wxml deleted file mode 100644 index cbdde59..0000000 --- a/pets-fe/pages/profile/profile.wxml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - {{pet.emoji}} - 📷 - - {{pet.name}} - {{pet.age}}|{{pet.weight}}|{{pet.stage}} - {{pet.breed}}{{pet.breed && pet.color ? ' · ' : ''}}{{pet.color}} - - - - - - 宠物档案(编辑)已完善 {{profilePct}}% › - 提醒设置 {{summary.reminders}} 条 › - 健康记录 {{summary.records}} 条 › - 多宠物管理 {{summary.pets}} 只 › - - - - 会员权益 {{summary.pro_status === 'active' ? '已开通' : '未开通'}} › - 数据导出 文本 › - 意见反馈 去填写 › - 关于我们 v0.2 › - - - - - - - - diff --git a/pets-fe/pages/profile/profile.wxss b/pets-fe/pages/profile/profile.wxss deleted file mode 100644 index 49ae875..0000000 --- a/pets-fe/pages/profile/profile.wxss +++ /dev/null @@ -1,20 +0,0 @@ -.page-body{padding:8rpx 40rpx calc(60rpx + env(safe-area-inset-bottom))} - -.profile-header{text-align:center;padding:24rpx 0 32rpx} -.ph-avatar{margin:0 auto 20rpx;width:152rpx;height:152rpx;font-size:76rpx;border-radius:56rpx;position:relative;overflow:visible} -.ph-avatar-img{width:100%;height:100%;border-radius:56rpx} -.ph-avatar-cam{position:absolute;right:-6rpx;bottom:-6rpx;width:52rpx;height:52rpx;line-height:52rpx;text-align:center;font-size:26rpx;background:#fff;border-radius:50%;box-shadow:var(--shadow)} -.ph-name{font-size:44rpx;font-weight:800} -.ph-meta{font-size:26rpx;margin-top:10rpx} - -.profile-list{background:#fff;border-radius:48rpx;box-shadow:var(--shadow);overflow:hidden;margin-bottom:28rpx} -.profile-row{ - display:flex;align-items:center;justify-content:space-between;min-height:104rpx;padding:0 32rpx; - border-bottom:1rpx solid var(--line);font-size:28rpx;font-weight:800; -} -.profile-row:last-child{border-bottom:0} -.pr-val{color:var(--muted);font-weight:700;font-size:26rpx} - -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js index 45bae8b..191692e 100644 --- a/pets-fe/pages/record/record.js +++ b/pets-fe/pages/record/record.js @@ -1,226 +1,49 @@ const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); +const recordTypes = require('../../utils/recordTypes.js'); -function pad(n) { - return n < 10 ? '0' + n : '' + n; +// 这一页只干一件事:把后端配置的记录事项按分组列出来,点一个进添加页。 +// 原来它还带着健康洞察、体重趋势、健康时间轴——那三块搬到 pages/history 了, +// 从报告页进。一个「我要记一笔」的页面上摆着三块只读图表, +// 用户每次都得先滚过去才能找到要点的东西。 +// tone-N 只给格子用(它自带背景色);组标题的颜色走 g-N。 +// short 是去掉「记录」后缀的组名——「日常记录」在标题位置念着啰嗦 +const G = { 'tone-1': 'g-1', 'tone-2': 'g-2', 'tone-3': 'g-3', 'tone-4': 'g-4' }; +function decorate(g) { + return { ...g, g: G[g.tone] || 'g-1', short: (g.label || '').replace(/记录$/, '') }; } -function fmtTime(iso) { - if (!iso) return ''; - const d = new Date(iso); - const now = new Date(); - const hm = pad(d.getHours()) + ':' + pad(d.getMinutes()); - if (d.toDateString() === now.toDateString()) return '今天 ' + hm; - const y = new Date(now.getTime() - 86400000); - if (d.toDateString() === y.toDateString()) return '昨天 ' + hm; - return d.getMonth() + 1 + '月' + d.getDate() + '日'; -} -function mapRecord(r) { - return { - id: r.id, - icon: r.icon || '✍️', - title: r.title, - desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''), - image: r.image_url || '', - }; -} - -// 时间轴类型筛选 -const REC_FILTERS = [ - { key: '', label: '全部' }, - { key: 'weight', label: '体重' }, - { key: 'poop', label: '便便' }, - { key: 'food', label: '饮食' }, - { key: 'symptom', label: '异常' }, - { key: 'medicine', label: '用药' }, - { key: 'vaccine', label: '疫苗' }, - { key: 'cost', label: '消费' }, - { key: 'photo', label: '照片' }, -]; - -function fmtDay(iso) { - if (!iso) return ''; - const d = new Date(iso); - return d.getMonth() + 1 + '月' + d.getDate() + '日'; -} - -// 用真实体重点生成趋势图:返回 { svg(折线), dots(可点圆点,坐标为百分比) } -function buildChart(points) { - const vals = points.map((p) => Number(p.value) || 0); - const W = 300, H = 100, pad = 12, n = vals.length; - let min = Math.min.apply(null, vals); - let max = Math.max.apply(null, vals); - if (max === min) max = min + 1; - const xs = (i) => (n === 1 ? W / 2 : pad + ((W - 2 * pad) * i) / (n - 1)); - const ys = (v) => H - pad - ((H - 2 * pad) * (v - min)) / (max - min); - const pts = vals.map((v, i) => xs(i).toFixed(1) + ',' + ys(v).toFixed(1)).join(' '); - const svg = - '' + - '' + - ''; - const dots = points.map((p, i) => ({ - xp: Number(((xs(i) / W) * 100).toFixed(2)), - yp: Number(((ys(vals[i]) / H) * 100).toFixed(2)), - value: vals[i], - date: fmtDay(p.occurred_at), - note: p.note || '', - delta: i > 0 ? Number((vals[i] - vals[i - 1]).toFixed(2)) : null, - })); - return { svg: 'data:image/svg+xml,' + encodeURIComponent(svg), dots }; -} - -const REC_PAGE = 6; Page({ - data: { - pet: {}, - timeline: [], - recPage: 1, - recTotal: 0, - recHasMore: false, - recFilters: REC_FILTERS, - recFilterIdx: 0, - trendSvg: '', - trendStats: null, - trendDots: [], - selDot: null, - selIdx: -1, - sheetShow: false, - sheetType: '', - }, + data: { pet: {}, typeGroups: [], sheetShow: false, sheetType: '' }, onLoad() { - this._unsub = store.subscribe((pet) => { - this.setData({ pet }); - if (!this._inited) return; // 首次由 onShow 加载 - this.loadRecords(); - }); + this._unsub = store.subscribe((pet) => this.setData({ pet })); }, onUnload() { if (this._unsub) this._unsub(); }, onShow() { - if (typeof this.getTabBar === 'function' && this.getTabBar()) { - this.getTabBar().setData({ selected: 2 }); - } + this.loadTypes(); store .ready() - .then(() => { - this._inited = true; - this.setData({ pet: store.getPet() }); - this.loadRecords(); - }) + .then(() => this.setData({ pet: store.getPet() })) .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); }, - curFilter() { - return this.data.recFilters[this.data.recFilterIdx].key; - }, - onFilterTap(e) { - const i = Number(e.currentTarget.dataset.index); - if (i === this.data.recFilterIdx) return; - this.setData({ recFilterIdx: i }, () => this.loadRecords()); - }, - // 长按删除一条记录 - onDeleteRecord(e) { - const r = this.data.timeline[e.currentTarget.dataset.index]; - if (!r || !r.id) return; - wx.showModal({ - title: '删除记录', - content: '确定删除「' + r.title + '」?删除后不可恢复。', - confirmColor: '#D9534F', - success: (res) => { - if (!res.confirm) return; - api - .deleteRecord(r.id) - .then(() => { - wx.showToast({ title: '已删除', icon: 'success' }); - this.loadRecords(); - }) - .catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' })); - }, - }); - }, - loadRecords() { - const id = store.currentPetId(); - if (!id) return; - api - .getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() }) - .then((res) => { - const list = (res.list || []).map(mapRecord); - this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) }); - }) - .catch((e) => toastErr(e)); - this.loadTrend(); - }, - loadMoreRecords() { - const id = store.currentPetId(); - if (!id) return; - const next = this.data.recPage + 1; - api - .getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() }) - .then((res) => { - const merged = this.data.timeline.concat((res.list || []).map(mapRecord)); - this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) }); - }) + loadTypes() { + if (this.data.typeGroups.length) return; + recordTypes + .load() + .then((groups) => this.setData({ typeGroups: groups.map(decorate) })) .catch(() => {}); }, - collapseRecords() { - this.loadRecords(); + goAdd(e) { + wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type }); }, - loadTrend() { - const id = store.currentPetId(); - if (!id) { - this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 }); - return; - } - api - .weightTrend(id) - .then((points) => { - points = points || []; - if (points.length < 2) { - this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 }); - return; - } - const vals = points.map((p) => Number(p.value) || 0); - const latest = vals[vals.length - 1]; - const delta = Number((latest - vals[0]).toFixed(2)); - const chart = buildChart(points); - const lastIdx = chart.dots.length - 1; - this.setData({ - trendSvg: chart.svg, - trendDots: chart.dots, - selDot: chart.dots[lastIdx], // 默认选中最新一次 - selIdx: lastIdx, - trendStats: { latest, delta, count: points.length }, - }); - }) - .catch(() => {}); - }, - onTapPoint(e) { - const i = e.currentTarget.dataset.index; - this.setData({ selDot: this.data.trendDots[i], selIdx: i }); - }, - previewImage(e) { - const src = e.currentTarget.dataset.src; - if (src) wx.previewImage({ urls: [src], current: src }); - }, - openSheet(e) { - this.openSheetType(e.currentTarget.dataset.type); - }, - openSheetType(type) { - this.setData({ sheetType: type, sheetShow: true }); + // 没建档时要能建档,所以这页留一个只用于 addPet 的弹层 + onAddPet() { + wx.navigateTo({ url: '/pages/petform/petform' }); }, closeSheet() { this.setData({ sheetShow: false }); }, - onSheetSaved() { - this.loadRecords(); - }, - onAddPet() { - this.openSheetType('addPet'); - }, - onFab() { - this.openSheetType('ai'); - }, onShareAppMessage() { return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' }; }, diff --git a/pets-fe/pages/record/record.json b/pets-fe/pages/record/record.json index 075a8b2..d447f47 100644 --- a/pets-fe/pages/record/record.json +++ b/pets-fe/pages/record/record.json @@ -1,8 +1,7 @@ { "usingComponents": { "nav-bar": "/components/nav-bar/nav-bar", - "pet-switch": "/components/pet-switch/pet-switch", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "pt-icon": "/components/pt-icon/index" } } diff --git a/pets-fe/pages/record/record.wxml b/pets-fe/pages/record/record.wxml index 554392c..3510b50 100644 --- a/pets-fe/pages/record/record.wxml +++ b/pets-fe/pages/record/record.wxml @@ -1,78 +1,32 @@ - + - - - 快速记录 - 先选择宠物,再记录体重、饮食、异常和消费。 + + + + 还没有毛孩子的档案 + 建好档才能记体重、便便、饮食这些,趋势图也是从这些记录里长出来的。 + 建一份档案 - - - - ⚖️体重 - 🍽️饮食 - 💩便便 - 🤒异常 - 💊用药 - 💰消费 - 💉疫苗 - 🛡️驱虫 - 📷照片 - - - - 体重趋势记录 - 最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次 - - - - - + + + + {{item.short}} + + + + {{t.label}} - - - {{selDot.date}} - {{selDot.value}}kg - {{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg - - 📝 {{selDot.note}} - 这次没有填备注 - - 点击圆点查看每次记录 - - 先记录几次体重,这里会自动画出趋势曲线 - - - - 健康时间轴摘要 - - {{item.label}} - - - - - {{item.icon}} - - {{item.title}}{{item.desc}} - - - - 这个分类下还没有记录 - 长按任意一条可删除 - 查看更多(共 {{recTotal}} 条) - 收起 - + + 还没有配置可记事项 - - + + diff --git a/pets-fe/pages/record/record.wxss b/pets-fe/pages/record/record.wxss index 331e61d..518c99c 100644 --- a/pets-fe/pages/record/record.wxss +++ b/pets-fe/pages/record/record.wxss @@ -1,61 +1,38 @@ -.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))} -.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800} -.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx} +/* 记录页只有这一页在用这套分组宫格,样式就放这里(不进 app.wxss)。 + 原来是整格铺 tone 浅色底 + 直接浮在页面渐变上,四个一行是一片肥色块。 + 改成:每组一张白卡收纳,颜色只集中在图标那个小圆角块上,标签用纯深色。 */ -.record-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24rpx;margin-bottom:28rpx} -.record-item{ - height:184rpx;background:#fff;border-radius:44rpx;box-shadow:var(--shadow); - display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16rpx; - font-weight:900;color:var(--text);font-size:28rpx; +/* .card 自带 --sp-5 padding 和 margin-bottom,这里只把上下内边距收紧一点, + 底部留白靠 card 的 margin-bottom,不再叠 margin-top */ +.rec-group{padding:var(--sp-4) var(--sp-4) var(--sp-3)} + +/* 组标题:一根同色系竖条 + 组名。颜色由外层 .g-N 决定 */ +.rec-ghead{ + display:flex;align-items:center;gap:var(--sp-2); + font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text); + margin-bottom:var(--sp-3); } -.ri-ic{font-size:56rpx} +.rec-bar{width:6rpx;height:28rpx;border-radius:3rpx;background:currentColor} +.g-1 .rec-bar{color:var(--primary)} +.g-2 .rec-bar{color:var(--green)} +.g-3 .rec-bar{color:var(--purple)} +.g-4 .rec-bar{color:var(--blue)} -.mini-chart{ - height:256rpx;border-radius:36rpx;margin-top:24rpx;position:relative;overflow:hidden; - background: - linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)), - repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx); +.rec-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-4) var(--sp-2)} + +.rec-item{ + display:flex;flex-direction:column;align-items:center;gap:var(--sp-2); + padding-bottom:var(--sp-2);border-radius:var(--r-md); + transition:transform .06s; } -.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto} -.dot-layer{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx} -.dot{position:absolute;width:40rpx;height:40rpx;margin:-20rpx 0 0 -20rpx;border-radius:50%;} -.dot::after{content:'';position:absolute;left:50%;top:50%;width:18rpx;height:18rpx;margin:-9rpx 0 0 -9rpx;border-radius:50%;background:#fff;border:4rpx solid #73BE9D;box-sizing:border-box} -.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:#73BE9D;box-shadow:0 0 0 6rpx rgba(115,190,157,.25)} -.trend-stats{margin-top:16rpx;font-size:26rpx;color:var(--muted)} -.point-detail{margin-top:20rpx;padding:24rpx 28rpx;border-radius:32rpx;background:#F3FAF6;border:1rpx solid #DCEFE4} -.pd-main{display:flex;align-items:baseline;gap:20rpx} -.pd-date{font-size:26rpx;color:var(--muted)} -.pd-weight{font-size:40rpx;font-weight:800;color:#2E7D5B} -.pd-delta{font-size:26rpx;font-weight:700} -.pd-delta.up{color:var(--red)} -.pd-delta.down{color:#2E7D5B} -.pd-note{margin-top:12rpx;font-size:28rpx;color:#4A4A46;line-height:1.5} -.pd-note.muted{color:var(--muted)} -.chart-hint{margin-top:12rpx;font-size:22rpx;color:var(--muted);text-align:center} -.chart-empty{margin-top:24rpx;padding:48rpx 24rpx;border-radius:36rpx;background:#FBFAF8;color:var(--muted);font-size:26rpx;text-align:center} +.rec-item-hover{transform:scale(.94)} -.health-timeline{display:flex;flex-direction:column;gap:20rpx} -.tl-empty{padding:36rpx 8rpx;text-align:center;color:var(--muted);font-size:26rpx} -.tl-more{margin-top:20rpx;padding:20rpx;text-align:center;color:var(--primary-dark);background:#FBFAF8;border-radius:28rpx;font-size:26rpx;font-weight:700} -.health-event{display:flex;gap:20rpx;background:#FBFAF8;border-radius:32rpx;padding:24rpx} -.event-dot{ - width:68rpx;height:68rpx;border-radius:26rpx;flex:none; - display:flex;align-items:center;justify-content:center;background:#FFF0D7;font-size:34rpx; +/* 图标的小圆角色块。tone-N(app.wxss)给它浅色底 + 深色 ink, + pt-icon 走 currentColor 正好染成 ink 色 */ +.rec-ic{ + width:96rpx;height:96rpx;border-radius:26rpx; + display:flex;align-items:center;justify-content:center; } -.he-b{font-size:28rpx;font-weight:700} -.he-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:6rpx} -.he-body{flex:1;min-width:0} -.he-img{margin-top:14rpx;width:220rpx;height:220rpx;border-radius:20rpx} -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} - -/* 时间轴类型筛选 */ -.rec-filter{white-space:nowrap;margin-bottom:20rpx} -.rf-chip{ - display:inline-block;padding:10rpx 26rpx;margin-right:14rpx;border-radius:999rpx; - background:#fff;border:1rpx solid var(--line);color:var(--muted);font-size:24rpx;font-weight:700; -} -.rf-chip.on{background:#FFF2DC;color:var(--primary-dark);border-color:#FFD39A} -.tl-tip{margin-top:16rpx;text-align:center;color:var(--muted);font-size:22rpx} +/* 标签用纯深色,不跟着 tone 变 —— 四组底下的字统一是深灰,才不花 */ +.rec-label{font-size:var(--fs-sm);color:var(--text-2);line-height:1.2} diff --git a/pets-fe/pages/relations/relations.js b/pets-fe/pages/relations/relations.js new file mode 100644 index 0000000..6cb5c7f --- /dev/null +++ b/pets-fe/pages/relations/relations.js @@ -0,0 +1,43 @@ +const api = require('../../utils/api.js'); +const { toastErr } = require('../../utils/ui.js'); + +Page({ + data: { uid: '', kind: 'following', title: '关注', list: [], page: 1, hasMore: false, loaded: false }, + onLoad(q) { + const kind = q && q.kind === 'followers' ? 'followers' : 'following'; + this.setData({ uid: (q && q.id) || '', kind, title: kind === 'followers' ? '粉丝' : '关注' }); + this.load(1); + }, + load(page) { + api + .relations(this.data.uid, this.data.kind, page) + .then((res) => { + // WXML 表达式不支持字符串下标,首字母得在这里算好 + const list = (res.list || []).map((u) => ({ ...u, initial: (u.nickname || '?').slice(0, 1) })); + const merged = page === 1 ? list : this.data.list.concat(list); + this.setData({ list: merged, page, loaded: true, hasMore: merged.length < (res.total || 0) }); + }) + .catch((e) => { + this.setData({ loaded: true }); + toastErr(e); + }); + }, + loadMore() { + if (this.data.hasMore) this.load(this.data.page + 1); + }, + goUser(e) { + wx.navigateTo({ url: `/pages/user/user?id=${e.currentTarget.dataset.id}` }); + }, + // 同样先本地翻转再发请求,失败回滚 + toggleFollow(e) { + const i = e.currentTarget.dataset.index; + const u = this.data.list[i]; + if (!u || u.is_self) return; + const next = !u.followed; + this.setData({ [`list[${i}].followed`]: next }); + (next ? api.followUser(u.id) : api.unfollowUser(u.id)).catch((err) => { + this.setData({ [`list[${i}].followed`]: !next }); + toastErr(err, '操作失败'); + }); + }, +}); diff --git a/pets-fe/pages/relations/relations.json b/pets-fe/pages/relations/relations.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/relations/relations.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/relations/relations.wxml b/pets-fe/pages/relations/relations.wxml new file mode 100644 index 0000000..09f45b3 --- /dev/null +++ b/pets-fe/pages/relations/relations.wxml @@ -0,0 +1,20 @@ + + + + + + + + {{item.initial}} + + {{item.nickname}}AI + + + + + {{kind === 'followers' ? '还没有人关注 TA' : '还没有关注任何人,去宠友圈逛逛'}} + + + diff --git a/pets-fe/pages/relations/relations.wxss b/pets-fe/pages/relations/relations.wxss new file mode 100644 index 0000000..1ac6fad --- /dev/null +++ b/pets-fe/pages/relations/relations.wxss @@ -0,0 +1,12 @@ +.rel{ + display:flex;align-items:center;gap:var(--sp-3); + background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1); + padding:var(--sp-3) var(--sp-4);margin-bottom:var(--sp-2); +} +.rel-av{ + width:80rpx;height:80rpx;border-radius:50%;flex:none;overflow:hidden; + display:flex;align-items:center;justify-content:center; + background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-lg);font-weight:var(--fw-b); +} +.rel-av-img{width:100%;height:100%} +.rel-name{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b)} diff --git a/pets-fe/pages/report/report.js b/pets-fe/pages/report/report.js index c064f85..9af86a0 100644 --- a/pets-fe/pages/report/report.js +++ b/pets-fe/pages/report/report.js @@ -1,20 +1,65 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); +const { syncTabBar } = require('../../utils/tabbar.js'); + +function fmtDay(iso) { + if (!iso) return ''; + const d = new Date(iso); + return d.getMonth() + 1 + '月' + d.getDate() + '日'; +} + +// 体重点 → 趋势图。返回 { svg(折线 data-uri), dots(可点圆点,坐标为百分比) }。 +// 从历史页搬过来的:报告页才是「看数据」的地方,图不该埋在二级页 +function buildChart(points) { + const vals = points.map((p) => Number(p.value) || 0); + const W = 300, H = 100, pad = 12, n = vals.length; + let min = Math.min.apply(null, vals); + let max = Math.max.apply(null, vals); + if (max === min) max = min + 1; + const xs = (i) => (n === 1 ? W / 2 : pad + ((W - 2 * pad) * i) / (n - 1)); + const ys = (v) => H - pad - ((H - 2 * pad) * (v - min)) / (max - min); + const pts = vals.map((v, i) => xs(i).toFixed(1) + ',' + ys(v).toFixed(1)).join(' '); + const svg = + '' + + '' + + ''; + const dots = points.map((p, i) => ({ + xp: Number(((xs(i) / W) * 100).toFixed(2)), + yp: Number(((ys(vals[i]) / H) * 100).toFixed(2)), + value: vals[i], + date: fmtDay(p.occurred_at), + note: p.note || '', + delta: i > 0 ? Number((vals[i] - vals[i - 1]).toFixed(2)) : null, + })); + return { svg: 'data:image/svg+xml,' + encodeURIComponent(svg), dots }; +} + +// 洞察等级 → 图标。alert/warn 用警告号,info 用趋势 +function mapInsight(i) { + return { ...i, icon: i.level === 'info' ? 'trend' : 'warn' }; +} Page({ data: { pet: {}, report: null, gainText: '+0.0', - bill: null, + insights: [], + trendSvg: '', + trendStats: null, + trendDots: [], + selDot: null, + selIdx: -1, summary: null, + bill: null, + billTop: null, sheetShow: false, sheetType: '', }, onLoad() { this._unsub = store.subscribe((pet) => { this.setData({ pet }); - if (!this._inited) return; // 首次由 onShow 加载 + if (!this._inited) return; this.loadData(); }); }, @@ -22,9 +67,7 @@ Page({ if (this._unsub) this._unsub(); }, onShow() { - if (typeof this.getTabBar === 'function' && this.getTabBar()) { - this.getTabBar().setData({ selected: 3 }); - } + syncTabBar(this); store .ready() .then(() => { @@ -37,18 +80,74 @@ Page({ loadData() { const id = store.currentPetId(); if (!id) return; - Promise.all([api.weeklyReport(id), api.getBill(id, 'month'), api.healthSummary(id)]) - .then(([report, bill, summary]) => { - const g = report.weight_gain || 0; + api + .weeklyReport(id) + .then((report) => { + const g = (report && report.weight_gain) || 0; + this.setData({ report, gainText: (g >= 0 ? '+' : '') + g.toFixed(1) }); + }) + .catch(() => {}); + api + .petInsights(id) + .then((list) => this.setData({ insights: (list || []).map(mapInsight) })) + .catch(() => this.setData({ insights: [] })); + api + .healthSummary(id) + .then((summary) => this.setData({ summary })) + .catch(() => {}); + // 账单只在报告页取一个概要,详细录入和流水在花销页 + api + .getBill(id, 'month') + .then((bill) => { + const cats = (bill && bill.categories) || []; + this.setData({ bill, billTop: cats.length ? cats[0] : null }); + }) + .catch(() => {}); + this.loadTrend(id); + }, + loadTrend(id) { + api + .weightTrend(id) + .then((points) => { + points = points || []; + if (points.length < 2) { + this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 }); + return; + } + const vals = points.map((p) => Number(p.value) || 0); + const latest = vals[vals.length - 1]; + const delta = Number((latest - vals[0]).toFixed(2)); + const chart = buildChart(points); + const last = chart.dots.length - 1; this.setData({ - report, - gainText: (g >= 0 ? '+' : '') + g.toFixed(1), - bill, - summary, + trendSvg: chart.svg, + trendDots: chart.dots, + selDot: chart.dots[last], // 默认选中最新一次 + selIdx: last, + trendStats: { latest, delta, count: points.length }, }); }) .catch(() => {}); }, + onTapPoint(e) { + const i = e.currentTarget.dataset.index; + this.setData({ selDot: this.data.trendDots[i], selIdx: i }); + }, + // 点一条洞察,跳到对应类型的记录入口(洞察带 action = 记录类型 code) + onTapInsight(e) { + const it = this.data.insights[e.currentTarget.dataset.index]; + if (it && it.action) wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + it.action }); + }, + // 体重趋势的「记一笔」直接去体重记录页(原来开的 weight 弹层已经搬成独立页) + goWeight() { + wx.navigateTo({ url: '/pages/addrecord/addrecord?type=weight' }); + }, + goExpense() { + wx.navigateTo({ url: '/pages/expense/expense' }); + }, + goHistory() { + wx.navigateTo({ url: '/pages/history/history' }); + }, openSheet(e) { this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); }, @@ -56,15 +155,14 @@ Page({ this.setData({ sheetShow: false }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); - }, - onFab() { - this.setData({ sheetType: 'ai', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onShareAppMessage() { - return { title: '看看我家毛孩子的成长报告', path: '/pages/report/report' }; + const n = (this.data.pet && this.data.pet.name) || '我家毛孩子'; + return { title: `${n} 的成长报告`, path: '/pages/report/report' }; }, onShareTimeline() { - return { title: '看看我家毛孩子的成长报告' }; + const n = (this.data.pet && this.data.pet.name) || '我家毛孩子'; + return { title: `${n} 的成长报告` }; }, }); diff --git a/pets-fe/pages/report/report.json b/pets-fe/pages/report/report.json index 075a8b2..381942f 100644 --- a/pets-fe/pages/report/report.json +++ b/pets-fe/pages/report/report.json @@ -3,6 +3,6 @@ "nav-bar": "/components/nav-bar/nav-bar", "pet-switch": "/components/pet-switch/pet-switch", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "pt-icon": "/components/pt-icon/index" } } diff --git a/pets-fe/pages/report/report.wxml b/pets-fe/pages/report/report.wxml index 5e175f6..f897697 100644 --- a/pets-fe/pages/report/report.wxml +++ b/pets-fe/pages/report/report.wxml @@ -1,59 +1,112 @@ + + - - - 成长报告 - 每只宠物的成长报告、账单与健康摘要。 + + + + 还没有毛孩子的档案 + 成长报告和账单都基于记录生成,先建个档开始记吧。 + 建一份档案 - - + + - 本周成长报告{{report.health_status || '稳定成长'}} - {{report.summary || '暂无本周数据'}} + 本周概览{{report.health_status || '稳定成长'}} + {{report.summary || '这周还没有足够的记录,多记几笔就有小结了'}} {{report.tasks_completed || 0}}完成任务 - {{gainText}}kg 增长 + {{gainText}}kg 变化 {{report.high_risk_count || 0}}高风险异常 - - - + + + + 健康洞察来自你的记录 + + + + {{item.title}} + + + {{item.detail}} + {{item.evidence}} + + + + - 健康摘要导出 + 体重趋势记一笔 + 最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次 + + + + + + + + + + {{selDot.date}} + {{selDot.value}}kg + {{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg + + {{selDot.note}} + 这次没有填备注 + + 点击圆点查看每次记录 + + 先记录几次体重,这里会自动画出趋势曲线 + + + + + 健康摘要就医摘要 疫苗进度{{summary.vaccine_progress || '—'}} 驱虫状态{{summary.deworm_status || '—'}} 体重趋势{{summary.weight_trend || '—'}} 异常记录{{summary.anomaly_count || 0}} 次 - - 养宠账单记一笔 - - ¥{{bill.total || 0}}本月花费 - ¥{{bill.max_single || 0}}最大支出 - {{bill.categories.length || 0}}消费类别 + + + 养宠花销去记账 › + + ¥{{bill.total || 0}} + 本月{{billTop ? ',' + billTop.category + '花得最多' : ''}} - + {{item.category}} ¥{{item.amount}} + 这个月还没有记过花销 - - Pro 会员 - 解锁 365 天计划、PDF 健康档案、月度报告、多宠物管理与年度账单。 - + + + + + 全部记录 + 按类型筛选、翻看历史 + + + - diff --git a/pets-fe/pages/report/report.wxss b/pets-fe/pages/report/report.wxss index 3a8f708..51ed02c 100644 --- a/pets-fe/pages/report/report.wxss +++ b/pets-fe/pages/report/report.wxss @@ -1,19 +1,60 @@ -.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))} -.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800} -.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx} - .report-card{background:linear-gradient(135deg,#FFFFFF,#F2FAF6)} -.rc-text{color:var(--muted);font-size:28rpx;line-height:1.6} -.stat-b{display:block;font-size:36rpx;margin-bottom:8rpx;font-weight:800} -.stat-s{font-size:22rpx;color:var(--muted)} +.rc-text{color:var(--text-2);font-size:var(--fs-md);line-height:1.6} +.rc-share{margin-top:var(--sp-4)} -.bill-bars{display:flex;flex-direction:column;gap:20rpx;margin-top:24rpx} -.bill-row{display:grid;grid-template-columns:88rpx 1fr 108rpx;gap:16rpx;align-items:center;font-size:24rpx} +/* ===== 健康洞察(从历史页搬来,报告页的主角)===== */ +.ins{border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-2)} +.ins:last-child{margin-bottom:0} +.ins-info{background:var(--surface-2)} +.ins-warn{background:var(--primary-soft)} +.ins-alert{background:var(--red-soft)} +.ins-head{display:flex;align-items:flex-start;gap:var(--sp-2)} +.ins-ic{flex:none;margin-top:2rpx} +.ins-info .ins-ic{color:var(--green-ink)} +.ins-warn .ins-ic{color:var(--primary-ink)} +.ins-alert .ins-ic{color:var(--red-ink)} +.ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4} +.ins-go{flex:none;color:var(--muted2);margin-top:2rpx} +.ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)} +.ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)} + +/* ===== 体重趋势曲线(从历史页搬来)===== */ +.trend-stats{margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted)} +.mini-chart{ + height:256rpx;border-radius:var(--r-md);margin-top:var(--sp-4);position:relative;overflow:hidden; + background: + linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)), + repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx); +} +.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto} +.dot-layer{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx} +.dot{position:absolute;width:40rpx;height:40rpx;margin:-20rpx 0 0 -20rpx;border-radius:50%} +.dot::after{content:'';position:absolute;left:50%;top:50%;width:18rpx;height:18rpx;margin:-9rpx 0 0 -9rpx;border-radius:50%;background:#fff;border:4rpx solid var(--green);box-sizing:border-box} +.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:var(--green);box-shadow:0 0 0 6rpx rgba(115,190,157,.25)} +.point-detail{ + margin-top:var(--sp-3);padding:var(--sp-4);border-radius:var(--r-md); + background:var(--green-soft);border:1rpx solid #DCEFE4; +} +.pd-main{display:flex;align-items:baseline;gap:var(--sp-3)} +.pd-date{font-size:var(--fs-sm);color:var(--muted)} +.pd-weight{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--green-ink)} +.pd-delta{font-size:var(--fs-sm);font-weight:var(--fw-b)} +.pd-delta.up{color:var(--red-ink)} +.pd-delta.down{color:var(--green-ink)} +.pd-note{margin-top:var(--sp-2);font-size:var(--fs-md);color:var(--text-2);line-height:1.5} +.pd-note.muted{color:var(--muted)} +.chart-hint{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted);text-align:center} + +/* ===== 养宠花销概要(详细在花销页)===== */ +.bill-card:active{opacity:.85} +.bill-sum{margin:var(--sp-2) 0 var(--sp-4)} +.bill-total{font-size:56rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.1} +.bill-cur{font-size:var(--fs-lg);color:var(--muted);margin-right:4rpx} +.bill-sub{font-size:var(--fs-sm);color:var(--muted);margin-top:4rpx} +.bill-bars{display:flex;flex-direction:column;gap:var(--sp-3)} +.bill-row{display:grid;grid-template-columns:88rpx 1fr 108rpx;gap:var(--sp-3);align-items:center;font-size:var(--fs-sm)} .bill-cat{color:var(--text)} -.bill-amt{font-weight:900;text-align:right} -.mini-bar{height:18rpx;border-radius:999rpx;background:#F0E7DD;overflow:hidden} -.mini-bar-fill{display:block;height:100%;border-radius:999rpx;background:linear-gradient(90deg,var(--blue),#A9C8F6)} - -/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ -page{height:100vh;overflow:hidden;display:flex;flex-direction:column} -.page-scroll{flex:1;min-height:0} +.bill-amt{font-weight:var(--fw-b);text-align:right} +/* 账单条比全局 .bar 细一档,蓝色是「花费」的语义色 */ +.mini-bar{height:var(--sp-2);border-radius:var(--r-full);background:#F0E7DD;overflow:hidden} +.mini-bar-fill{display:block;height:100%;border-radius:var(--r-full);background:var(--blue)} diff --git a/pets-fe/pages/profile/profile.js b/pets-fe/pages/settings/settings.js similarity index 76% rename from pets-fe/pages/profile/profile.js rename to pets-fe/pages/settings/settings.js index 167b9e0..0d8ae85 100644 --- a/pets-fe/pages/profile/profile.js +++ b/pets-fe/pages/settings/settings.js @@ -40,8 +40,14 @@ Page({ openSheet(e) { this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); }, + // 自己的关注/粉丝。之前只有帖子上的「+关注」按钮,关注完没有任何地方能看 + goRelations(e) { + const uid = (this.data.user && this.data.user.id) || ''; + if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' }); + wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.currentTarget.dataset.kind}` }); + }, goRecord() { - wx.switchTab({ url: '/pages/record/record' }); + wx.navigateTo({ url: '/pages/record/record' }); }, onPickAvatar() { upload @@ -58,18 +64,16 @@ Page({ }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onEditPet() { - this.setData({ sheetType: 'editPet', sheetShow: true }); + const id = store.currentPetId(); + wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') }); }, closeSheet() { this.setData({ sheetShow: false }); this.loadSummary(); }, - onFab() { - this.setData({ sheetType: 'ai', sheetShow: true }); - }, onShareAppMessage() { return { title: '肉垫计划 · 新手养宠助手', path: '/pages/home/home' }; }, diff --git a/pets-fe/pages/profile/profile.json b/pets-fe/pages/settings/settings.json similarity index 64% rename from pets-fe/pages/profile/profile.json rename to pets-fe/pages/settings/settings.json index 24ea6ea..fa34741 100644 --- a/pets-fe/pages/profile/profile.json +++ b/pets-fe/pages/settings/settings.json @@ -2,6 +2,7 @@ "usingComponents": { "nav-bar": "/components/nav-bar/nav-bar", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab" + "fab": "/components/fab/fab", + "pt-icon": "/components/pt-icon/index" } } diff --git a/pets-fe/pages/settings/settings.wxml b/pets-fe/pages/settings/settings.wxml new file mode 100644 index 0000000..3c02ed8 --- /dev/null +++ b/pets-fe/pages/settings/settings.wxml @@ -0,0 +1,19 @@ + + + + + + 提醒设置{{summary.reminders}} 条 + + + + 会员权益{{summary.pro_status === 'active' ? '已开通' : '未开通'}} + 数据导出文本 + 意见反馈去填写 + 关于我们v0.2 + + + + + + diff --git a/pets-fe/pages/settings/settings.wxss b/pets-fe/pages/settings/settings.wxss new file mode 100644 index 0000000..80c2b2e --- /dev/null +++ b/pets-fe/pages/settings/settings.wxss @@ -0,0 +1,11 @@ +.profile-header{text-align:center;padding:var(--sp-4) 0 var(--sp-5)} +.ph-avatar{margin:0 auto var(--sp-3);width:152rpx;height:152rpx;font-size:76rpx;border-radius:var(--r-lg);position:relative;overflow:visible} +.ph-avatar-img{width:100%;height:100%;border-radius:var(--r-lg)} +.ph-avatar-cam{ + position:absolute;right:-6rpx;bottom:-6rpx;width:52rpx;height:52rpx; + display:flex;align-items:center;justify-content:center;color:var(--text-2); + background:#fff;border-radius:50%;box-shadow:var(--sd-1); +} +.ph-name{font-size:var(--fs-2xl);font-weight:var(--fw-b)} +.ph-meta{font-size:var(--fs-sm);margin-top:var(--sp-1)} + diff --git a/pets-fe/pages/user/user.js b/pets-fe/pages/user/user.js new file mode 100644 index 0000000..60c66be --- /dev/null +++ b/pets-fe/pages/user/user.js @@ -0,0 +1,97 @@ +const api = require('../../utils/api.js'); +const store = require('../../utils/store.js'); +const { toastErr } = require('../../utils/ui.js'); + +const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' }; + +function fmtAgo(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const min = Math.floor((Date.now() - d.getTime()) / 60000); + if (min < 1) return '刚刚'; + if (min < 60) return min + ' 分钟前'; + if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前'; + if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前'; + const p = (n) => (n < 10 ? '0' + n : '' + n); + return p(d.getMonth() + 1) + '-' + p(d.getDate()); +} + +Page({ + data: { uid: '', card: null, posts: [], page: 1, hasMore: false, loading: true }, + onLoad(q) { + const uid = q && q.id; + if (!uid) return wx.showToast({ title: '缺少用户', icon: 'none' }); + this.setData({ uid }); + this.loadCard(); + this.loadPosts(1); + }, + loadCard() { + api + .userCard(this.data.uid) + .then((card) => this.setData({ card, loading: false })) + .catch((e) => { + this.setData({ loading: false }); + toastErr(e); + }); + }, + loadPosts(page) { + api + .userPosts(this.data.uid, page) + .then((res) => { + const list = (res.list || []).map((p) => { + const tag = (p.tags || [])[0] || ''; + return { + ...p, + timeText: fmtAgo(p.created_at), + author_emoji: p.author_emoji || '🐾', + tag, + tagClass: TAG_CLASS[tag] || '', + }; + }); + const merged = page === 1 ? list : this.data.posts.concat(list); + this.setData({ posts: merged, page, hasMore: merged.length < (res.total || 0) }); + }) + .catch(() => {}); + }, + loadMore() { + if (this.data.hasMore) this.loadPosts(this.data.page + 1); + }, + // 关注状态先本地翻转,请求失败再翻回去——等接口返回按钮才变会显得很迟钝 + toggleFollow() { + const c = this.data.card; + if (!c || c.is_self) return; + const next = !c.followed; + this.setData({ + 'card.followed': next, + 'card.follower_count': Math.max(0, c.follower_count + (next ? 1 : -1)), + }); + (next ? api.followUser(c.id) : api.unfollowUser(c.id)).catch((e) => { + this.setData({ 'card.followed': !next, 'card.follower_count': c.follower_count }); + toastErr(e, '操作失败'); + }); + }, + goRelations(e) { + const kind = e.detail.kind; + wx.navigateTo({ url: `/pages/relations/relations?id=${this.data.uid}&kind=${kind}` }); + }, + // 自己看自己的主页时,头部按钮是「装扮」而不是「关注」 + goDecorate() { + wx.navigateTo({ url: '/pages/decorate/decorate' }); + }, + // 别人的宠物只是名片,点了不跳;自己的才切过去看 + goPet(e) { + const c = this.data.card; + if (!c || !c.is_self) return; + store.switchPet(e.detail.id); + wx.switchTab({ url: '/pages/home/home' }); + }, + previewImage(e) { + const { urls, cur } = e.currentTarget.dataset; + if (urls && urls.length) wx.previewImage({ urls, current: cur }); + }, + onShareAppMessage() { + const n = (this.data.card && this.data.card.nickname) || '宠友'; + return { title: `${n} 的主页`, path: `/pages/user/user?id=${this.data.uid}` }; + }, +}); diff --git a/pets-fe/pages/user/user.json b/pets-fe/pages/user/user.json new file mode 100644 index 0000000..b4ba05a --- /dev/null +++ b/pets-fe/pages/user/user.json @@ -0,0 +1,7 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index", + "profile-head": "/components/profile-head/index" + } +} diff --git a/pets-fe/pages/user/user.wxml b/pets-fe/pages/user/user.wxml new file mode 100644 index 0000000..39b4d14 --- /dev/null +++ b/pets-fe/pages/user/user.wxml @@ -0,0 +1,42 @@ + +module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } + + + + + + 加载中… + + + + + + + {{item.author_emoji}} + + {{item.author_name}} + {{item.timeText}} + + {{item.tag}} + + {{item.content}} + + + + {{img}} + + + + {{item.like_count || 0}} + {{item.comment_count || 0}} + + + + TA 还没有发过帖子 + + + diff --git a/pets-fe/pages/user/user.wxss b/pets-fe/pages/user/user.wxss new file mode 100644 index 0000000..dba8ff6 --- /dev/null +++ b/pets-fe/pages/user/user.wxss @@ -0,0 +1,2 @@ +/* 头部整块(头像/昵称/签名/计数/养宠数据/宠物墙)已抽成 components/profile-head, + 帖子卡走 app.wxss 的 .post-card。这里没有页面独有样式了。 */ diff --git a/pets-fe/preview-v2.html b/pets-fe/preview-v2.html new file mode 100644 index 0000000..f34d32d --- /dev/null +++ b/pets-fe/preview-v2.html @@ -0,0 +1,502 @@ +修复验证 +
+

① Pro 长文案按钮(原来撑爆)

+
Pro 会员(即将开放)
+
添加毛孩子
+ +

② 用户主页(帖子样式 + 首字头像)

+
+
+
奶牛猫饲养员AI
+
+
1帖子
+
0关注
+
1粉丝
+
已关注
+
+
🐮
+
奶牛猫饲养员
42 分钟前
+
经验
+
刚打完第二针疫苗,小家伙有点蔫蔫的…
+
+
8
+
1
+ +

③ 关注/粉丝列表

+
奶牛猫饲养员AI
+
布偶少爷
+
\ No newline at end of file diff --git a/pets-fe/preview-v3.html b/pets-fe/preview-v3.html new file mode 100644 index 0000000..ef9d45f --- /dev/null +++ b/pets-fe/preview-v3.html @@ -0,0 +1,414 @@ + + + +肉垫计划 · 视觉稿 v3 + + +

肉垫计划 · 视觉稿 v3

+

+用的是 app.wxss 的真 token 值和真图标资源(Tabler mask,含本次新增的 15 个),不是另画一套。 +rpx 按 2:1 折成 px,所以这里的尺寸就是小程序里的尺寸。
+要定的只有第 ① 项:门面色块和浅底内容的接缝怎么处理。 +

+ +

① 接缝方案 —— 三选一

+

+关键约束(跟规划里写的不一样,实际读代码才发现):nav-barposition:fixed, +背景是半透明奶白 + backdrop-filter,底边还有一层 mask 淡出——当初就是为了别和页面渐变留一道硬接缝。
+所以品牌色块要盖到最顶部,导航栏必须参与:得给 nav-bar 加一个 tone 属性, +在色块上时不铺奶白、文字转深棕。A / B 需要这个改动,C 不需要。 +

+
+
+
+ +
钞票奶盖
+
+
+
🐶
+
钞票
+
柯基2岁7个月射手座
+
+
+
我们一起生活的第 862
+
+
+
+

A · 色块只到宠物切换条
门面卡压在接缝上,一半在色块里。

+
+
+
+ +
钞票奶盖
+
+
+
🐶
+
钞票
+
柯基2岁7个月射手座
+
+
+
我们一起生活的第 862
+
+
+
+

B · 色块整个包住门面卡
最接近竞品。白卡完整浮在黄底上。

+
+
+
+ +
钞票奶盖
+
+
+
🐶
+
钞票
+
柯基2岁7个月射手座
+
+
+
我们一起生活的第 862
+
+
+
+

C · 色块渐隐,没有硬边界
不动 nav-bar。改动最小,也最不像竞品。

+
+
+ +

② 记录页:24 种 4 组 新增 15 种

+

+四组各一个色系,全部走现成的 --primary / green / purple / bluesoft/ink 二件套,没有新色值。 +竞品看着「彩色」的真正原因是浅色圆底 + 分组配色,不是插画;图标仍是线性 Tabler mask,零美术成本。 +

+
+
+
+ +
日常6
体重
+
排便
+
饮食
+
喝水
+
记账
+
照片
+
健康7
异常
+
疫苗
+
驱虫
+
体检
+
看病
+
给药
+
保健品
+
洗护6
洗澡
+
剪指甲
+
洗耳朵
+
刷牙
+
梳毛
+
美容
+
清洁5
换猫砂
+
洗猫砂盆
+
洗食盆
+
洗水盆
+
消毒
+
+

新:24 种 4 组
一屏看到日常+健康,滚一下到洗护/清洁。

+
+
+
+ +
体重
+
便便
+
饮食
+
异常
+
消费
+
用药
+
照片
+
疫苗
+
驱虫
+
+

现状:9 种一锅
全是低频医疗和记账。今天打开大概率没什么可记。

+
+
+
+
新增的 15 个图标
+
喝水
+
洗澡
+
剪指甲
+
洗耳朵
+
刷牙
+
梳毛
+
美容
+
换猫砂
+
洗猫砂盆
+
洗食盆
+
洗水盆
+
消毒
+
体检
+
看病
+
保健品
+

+ 从本地那份 tabler-icons-3.46.0 里挑的真源,不是我凭记忆画路径。 + 已解码抽检确认路径非空——Tabler 是 stroke 描边,这套方案唯一的坑就是空字形, + 转字体必踩,走 mask 不会。

+
+
+
+ +

③ 身份卡

+

+绘制复用现有海报那套 canvas 2d(bottom-sheet.js 270-380 行),换的是内容不是功能。
+唯一新增后端工作:小程序码要调微信 wxacode.getUnlimited,AppID/Secret 配置里已有、接口还没写。 +身份 ID 用「品种码 + 生日 + 序号」本地拼,不入库。 +

+
+
+
+ +
+
+
+
+
🐶
+
+
姓名钞票
+
性别  品种柯基
+
出生2023 年 12 月 15 日
+
介绍汪星人来地球啦~
+
+
+
身份 ID1116 9832 0231 2150
+
+
+
+
宠星人地球移民
+
居民身份卡
+
签发机构 肉垫计划
有效期限 2023.12.15 - 永远
+
+
小程序码
(待接)
+
+
+
存相册
+
分享
+
+
+
+
+

身份卡
价值全在「可炫耀、可分享」,不在信息量。

+
+
+ + diff --git a/pets-fe/project.config.json b/pets-fe/project.config.json index 27af2c5..17dcaad 100644 --- a/pets-fe/project.config.json +++ b/pets-fe/project.config.json @@ -1,7 +1,36 @@ { "description": "肉垫计划 - 新手养宠小程序", "packOptions": { - "ignore": [], + "ignore": [ + { + "type": "file", + "value": "preview.html" + }, + { + "type": "file", + "value": "preview-v2.html" + }, + { + "type": "folder", + "value": "assets/options" + }, + { + "type": "file", + "value": "assets/logo-512.png" + }, + { + "type": "file", + "value": "assets/logo-lockup.svg" + }, + { + "type": "folder", + "value": "scripts" + }, + { + "type": "file", + "value": "preview-v3.html" + } + ], "include": [] }, "setting": { @@ -50,4 +79,4 @@ }, "miniprogramRoot": "./", "simulatorPluginLibVersion": {} -} \ No newline at end of file +} diff --git a/pets-fe/scripts/check.js b/pets-fe/scripts/check.js new file mode 100644 index 0000000..fc76aed --- /dev/null +++ b/pets-fe/scripts/check.js @@ -0,0 +1,169 @@ +// 用法:node scripts/check.js(在 pets-fe 目录下跑,或直接给全路径) +// +// 静态自查:class 有没有人定义、而且是不是「在能生效的地方」定义、pt-icon 的 name +// 是否存在、绑的事件方法是否存在、组件是否已注册。 +// +// 防的是「漏改一处 → 元素完全裸奔」这种不报错、不在 diff 里、只在特定页面/弹层才暴露 +// 的问题。这个项目已经踩过两次同一个坑:.post-card 留在 community.wxss 却被用户主页 +// 用(帖子没样式)、.profile-row 留在 settings.wxss 却被「我的」用(入口整个散架)。 +// 所以 class 检查是按作用域来的,不是全局并一起看——那样恰好检查不出这类问题。 +const fs = require('fs'); +const path = require('path'); + +const FE = path.resolve(__dirname, '..'); +const walk = (d, out = []) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) { if (!['node_modules', '.git'].includes(e.name)) walk(p, out); } + else out.push(p); + } + return out; +}; +const files = walk(FE); +const read = (p) => fs.readFileSync(p, 'utf8'); +const rel = (p) => path.relative(FE, p); + +// ── 一个 wxss 里定义的 class,跟着 @import 一起收 ──────────────────────────── +function classesOf(wxss, seen = new Set()) { + const out = new Set(); + if (!fs.existsSync(wxss) || seen.has(wxss)) return out; + seen.add(wxss); + const src = read(wxss); + for (const m of src.matchAll(/\.([a-zA-Z][\w-]*)/g)) out.add(m[1]); + for (const m of src.matchAll(/@import\s+["']([^"']+)["']/g)) { + for (const c of classesOf(path.resolve(path.dirname(wxss), m[1]), seen)) out.add(c); + } + return out; +} + +// ── 作用域 ───────────────────────────────────────────────────────────────── +// app.wxss 是全局的;页面 wxss 只对自己那一页生效;组件 wxss 只对组件自己的节点生效。 +// 页面之间互不可见——这正是前面两次事故的成因。 +const GLOBAL = classesOf(path.join(FE, 'app.wxss')); + +// 谁定义了这个 class,报错时好指路 +const whereDefined = new Map(); +for (const f of files.filter((f) => f.endsWith('.wxss'))) { + for (const c of classesOf(f)) { + if (!whereDefined.has(c)) whereDefined.set(c, []); + whereDefined.get(c).push(rel(f)); + } +} + +const iconNames = new Set(); +for (const m of read(path.join(FE, 'styles/iconfont.wxss')).matchAll(/\.pt-i-([\w-]+)\{/g)) iconNames.add(m[1]); + +const problems = []; +for (const f of files.filter((f) => f.endsWith('.wxml'))) { + const src = read(f); + const r = rel(f); + const isComponent = /^(components|custom-tab-bar)\//.test(r); + + // 这个 wxml 能看到的 class:自己那个同名 wxss,加上全局(组件要开了 addGlobalClass 才有) + const own = classesOf(f.replace(/\.wxml$/, '.wxss')); + const js = f.replace(/\.wxml$/, '.js'); + const seesGlobal = !isComponent || + (fs.existsSync(js) && read(js).includes('addGlobalClass')); + const scope = seesGlobal ? new Set([...own, ...GLOBAL]) : own; + + for (const m of src.matchAll(/class="([^"]*)"/g)) { + // 先把 {{...}} 整段挖掉(里面是表达式不是 class),再看剩下的静态部分; + // 表达式里的三元字面量 'active' 单独收集 + const dyn = [...m[1].matchAll(/\{\{([^}]*)\}\}/g)].map((x) => x[1]).join(' '); + const statics = m[1].replace(/\{\{[^}]*\}\}/g, ' '); + const names = statics.split(/\s+/).filter(Boolean); + // 只认三元表达式里的字面量(? 'x' : 'y');wxs 函数的字符串入参不是 class + for (const q of dyn.matchAll(/[?:]\s*'([\w-]+)'/g)) names.push(q[1]); + // 纯布局壳,没样式是故意的 + const LAYOUT_ONLY = new Set(['greeting', 'tab-text', 'aiPlan']); + for (const raw of names) { + if (LAYOUT_ONLY.has(raw)) continue; + // class="row-{{item.role}}" 挖掉表达式后剩个 row-,是拼接前缀不是类名 + if (raw.endsWith('-')) continue; + if (scope.has(raw)) continue; + const at = whereDefined.get(raw); + if (at && at.length) { + // 最坑的一种:class 有定义,但定义在别的页面/组件里,这一页拿不到,元素裸奔 + problems.push(`${r}: class "${raw}" 只定义在 ${at.join(' / ')} —— 页面级 wxss 跨不了页,这里是裸的。两页共用就提到 app.wxss`); + } else { + problems.push(`${r}: class "${raw}" 没有任何 wxss 定义`); + } + } + } + + // wx:if / wx:elif / wx:else 链的第一支必须是 wx:if。 + // 删掉链首那一支(比如把 9 个记录分支整体搬走)会让整条链从 wx:elif 开头, + // 小程序报「wx:if not found」直接编译不过——整个组件一起挂,不是少个样式。 + // + // 判断必须靠真正的标签栈,不能靠缩进:这个项目里 scroll-view 和它的子节点 + // 缩进是一样深的,按缩进判「同层」会把父节点当成兄弟节点,什么都查不出来。 + { + const noComment = src.replace(//g, ''); + const stack = [{ last: null }]; // 每层记「上一个兄弟节点带的条件指令」 + const re = /<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g; + let m; + while ((m = re.exec(noComment))) { + const [, closing, tag, rawAttrs] = m; + // 自闭合要从 attrs 尾部判断:属性那段是贪婪的,会把 /> 里的 / 一起吃掉, + // 单独用一个 (\/?) 捕获组永远是空的。多行写的 就是这么 + // 被当成非自闭合、入了栈,把整份栈的层级全带偏,满屏误报。 + const selfClose = /\/\s*$/.test(rawAttrs); + const attrs = rawAttrs.replace(/\/\s*$/, ''); + const top = stack[stack.length - 1]; + if (closing) { + if (stack.length > 1) stack.pop(); + continue; + } + const cond = /\bwx:if\b/.test(attrs) ? 'if' + : /\bwx:elif\b/.test(attrs) ? 'elif' + : /\bwx:else\b/.test(attrs) ? 'else' : ''; + if (cond === 'elif' || cond === 'else') { + if (top.last !== 'if' && top.last !== 'elif') { + const line = noComment.slice(0, m.index).split('\n').length; + problems.push(`${r}: 第 ${line} 行附近 <${tag} wx:${cond}> 前面没有 wx:if` + + ` —— 链首被删了,这个文件编译不过`); + } + } + // 没带条件的兄弟节点会把链断开,所以无条件覆盖 + top.last = cond || null; + // 不搞「void 标签」白名单:wxml 里 是显式闭合的, + // 把它当 void 就会「开标签不入栈、闭标签却弹栈」,一路把父级弹光, + // 于是满屏误报。只认 /> 自闭合,其余一律入栈。 + if (!selfClose) stack.push({ last: null }); + } + } + + // 事件绑定的方法必须真的存在——绑了个不存在的方法,点了没反应且不报错 + if (fs.existsSync(js)) { + const code = read(js); + const handlers = new Set(); + for (const m of src.matchAll(/\b(?:bind|catch)(?::)?[a-z]+="([A-Za-z_$][\w$]*)"/g)) handlers.add(m[1]); + for (const h of handlers) { + if (!new RegExp(`(^|[\\s,{])${h}\\s*[(:]`, 'm').test(code)) { + problems.push(`${r}: 绑了 ${h},但 ${path.basename(js)} 里没有这个方法 —— 点了没反应`); + } + } + } + + // pt-icon 的静态 name + for (const m of src.matchAll(/]*name="([^"{]*)"/g)) { + if (m[1] && !iconNames.has(m[1])) problems.push(`${r}: pt-icon name="${m[1]}" 图标不存在`); + } + // 用到的自定义组件是否在同名 .json 里注册 + const json = f.replace(/\.wxml$/, '.json'); + const reg = fs.existsSync(json) ? Object.keys(JSON.parse(read(json)).usingComponents || {}) : []; + const known = ['nav-bar', 'pet-switch', 'bottom-sheet', 'fab', 'pt-icon', 'seg-tabs', 'stat-ring', 'profile-head']; + for (const tag of known) { + if (new RegExp('<' + tag + '[\\s>]').test(src) && !reg.includes(tag)) { + problems.push(`${r}: 用了 <${tag}> 但 ${path.basename(json)} 没有注册`); + } + } +} + +if (problems.length) { + console.log('发现 ' + problems.length + ' 处问题:'); + problems.forEach((p) => console.log(' ✗ ' + p)); + process.exit(1); +} +console.log('✓ class 作用域 / 图标名 / 事件方法 / 组件注册 全部对得上(检查了 ' + + files.filter((f) => f.endsWith('.wxml')).length + ' 个 wxml,' + iconNames.size + ' 个图标)'); diff --git a/pets-fe/styles/iconfont.wxss b/pets-fe/styles/iconfont.wxss new file mode 100644 index 0000000..0e08a25 --- /dev/null +++ b/pets-fe/styles/iconfont.wxss @@ -0,0 +1,59 @@ +/* 图标资源:由 Tabler Icons(MIT) 生成,勿手改。 + 用 mask-image 而非字体:Tabler 是 stroke 描边图标,转字体需先把描边展开成填充轮廓, + 漏了这步会生成空字形,真机表现和「字体没加载」一模一样,极难排查。mask 走 alpha,没这个坑。 + 染色靠 background-color:currentColor,选中态直接改文字色即可。 */ +.pt-i-weight{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik03IDIwbDEwIDAiIC8+PHBhdGggZD0iTTYgNmw2IC0xbDYgMSIgLz48cGF0aCBkPSJNMTIgM2wwIDE3IiAvPjxwYXRoIGQ9Ik05IDEybC0zIC02bC0zIDZhMyAzIDAgMCAwIDYgMCIgLz48cGF0aCBkPSJNMjEgMTJsLTMgLTZsLTMgNmEzIDMgMCAwIDAgNiAwIiAvPjwvc3ZnPg==")} +.pt-i-poop{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMCAxMmguMDEiIC8+PHBhdGggZD0iTTE0IDEyaC4wMSIgLz48cGF0aCBkPSJNMTAgMTZhMy41IDMuNSAwIDAgMCA0IDAiIC8+PHBhdGggZD0iTTExIDRjMiAwIDMuNSAxLjUgMy41IDRsLjE2NCAwYTIuNSAyLjUgMCAwIDEgMi4xOTYgMy4zMmEzIDMgMCAwIDEgMS42MTUgMy4wNjNhMyAzIDAgMCAxIC0xLjI5OSA1LjYwN2wtLjE3NiAwaC0xMGEzIDMgMCAwIDEgLTEuNDc0IC01LjYxM2EzIDMgMCAwIDEgMS42MTUgLTMuMDYyYTIuNSAyLjUgMCAwIDEgMi4xOTUgLTMuMzJsLjE2NCAwYzEuNSAwIDIuNSAtMiAxLjUgLTRsMCAuMDA1IiAvPjwvc3ZnPg==")} +.pt-i-food{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDExaDE2YTEgMSAwIDAgMSAxIDF2LjVjMCAxLjUgLTIuNTE3IDUuNTczIC00IDYuNXYxYTEgMSAwIDAgMSAtMSAxaC04YTEgMSAwIDAgMSAtMSAtMXYtMWMtMS42ODcgLTEuMDU0IC00IC01IC00IC02LjV2LS41YTEgMSAwIDAgMSAxIC0xIiAvPjxwYXRoIGQ9Ik04IDdjMS42NTcgMCAzIC0uODk1IDMgLTJzLTEuMzQzIC0yIC0zIC0ycy0zIC44OTUgLTMgMnMxLjM0MyAyIDMgMiIgLz48cGF0aCBkPSJNMTEgNWg5IiAvPjwvc3ZnPg==")} +.pt-i-symptom{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xOSA1YTIuODI4IDIuODI4IDAgMCAxIDAgNGwtOCA4aC00di00bDggLThhMi44MjggMi44MjggMCAwIDEgNCAwIiAvPjxwYXRoIGQ9Ik0xNiA3bC0xLjUgLTEuNSIgLz48cGF0aCBkPSJNMTMgMTBsLTEuNSAtMS41IiAvPjxwYXRoIGQ9Ik0xMCAxM2wtMS41IC0xLjUiIC8+PHBhdGggZD0iTTcgMTdsLTMgMyIgLz48L3N2Zz4=")} +.pt-i-medicine{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00LjUgMTIuNWw4IC04YTQuOTQgNC45NCAwIDAgMSA3IDdsLTggOGE0Ljk0IDQuOTQgMCAwIDEgLTcgLTciIC8+PHBhdGggZD0iTTguNSA4LjVsNyA3IiAvPjwvc3ZnPg==")} +.pt-i-cost{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDEyYTkgOSAwIDEgMCAxOCAwYTkgOSAwIDEgMCAtMTggMCIgLz48cGF0aCBkPSJNOSAxM2g2IiAvPjxwYXRoIGQ9Ik05IDhsMyA0LjUiIC8+PHBhdGggZD0iTTE1IDhsLTMgNC41djQuNSIgLz48L3N2Zz4=")} +.pt-i-vaccine{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNyAzbDQgNCIgLz48cGF0aCBkPSJNMTkgNWwtNC41IDQuNSIgLz48cGF0aCBkPSJNMTEuNSA2LjVsNiA2IiAvPjxwYXRoIGQ9Ik0xNi41IDExLjVsLTYuNSA2LjVoLTR2LTRsNi41IC02LjUiIC8+PHBhdGggZD0iTTcuNSAxMi41bDEuNSAxLjUiIC8+PHBhdGggZD0iTTEwLjUgOS41bDEuNSAxLjUiIC8+PHBhdGggZD0iTTMgMjFsMyAtMyIgLz48L3N2Zz4=")} +.pt-i-deworm{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMS40NiAyMC44NDZhMTIgMTIgMCAwIDEgLTcuOTYgLTE0Ljg0NmExMiAxMiAwIDAgMCA4LjUgLTNhMTIgMTIgMCAwIDAgOC41IDNhMTIgMTIgMCAwIDEgLS4wOSA3LjA2IiAvPjxwYXRoIGQ9Ik0xNSAxOWwyIDJsNCAtNCIgLz48L3N2Zz4=")} +.pt-i-photo{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDdoMWEyIDIgMCAwIDAgMiAtMmExIDEgMCAwIDEgMSAtMWg2YTEgMSAwIDAgMSAxIDFhMiAyIDAgMCAwIDIgMmgxYTIgMiAwIDAgMSAyIDJ2OWEyIDIgMCAwIDEgLTIgMmgtMTRhMiAyIDAgMCAxIC0yIC0ydi05YTIgMiAwIDAgMSAyIC0yIiAvPjxwYXRoIGQ9Ik05IDEzYTMgMyAwIDEgMCA2IDBhMyAzIDAgMCAwIC02IDAiIC8+PC9zdmc+")} +.pt-i-home{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDEybC0yIDBsOSAtOWw5IDlsLTIgMCIgLz48cGF0aCBkPSJNNSAxMnY3YTIgMiAwIDAgMCAyIDJoMTBhMiAyIDAgMCAwIDIgLTJ2LTciIC8+PHBhdGggZD0iTTkgMjF2LTZhMiAyIDAgMCAxIDIgLTJoMmEyIDIgMCAwIDEgMiAydjYiIC8+PC9zdmc+")} +.pt-i-plan{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDdhMiAyIDAgMCAxIDIgLTJoMTJhMiAyIDAgMCAxIDIgMnYxMmEyIDIgMCAwIDEgLTIgMmgtMTJhMiAyIDAgMCAxIC0yIC0ydi0xMiIgLz48cGF0aCBkPSJNMTYgM3Y0IiAvPjxwYXRoIGQ9Ik04IDN2NCIgLz48cGF0aCBkPSJNNCAxMWgxNiIgLz48cGF0aCBkPSJNOCAxNHY0IiAvPjxwYXRoIGQ9Ik0xMiAxNHY0IiAvPjxwYXRoIGQ9Ik0xNiAxNHY0IiAvPjwvc3ZnPg==")} +.pt-i-record{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDIwaDRsMTAuNSAtMTAuNWEyLjgyOCAyLjgyOCAwIDEgMCAtNCAtNGwtMTAuNSAxMC41djQiIC8+PHBhdGggZD0iTTEzLjUgNi41bDQgNCIgLz48cGF0aCBkPSJNMTYgMTloNiIgLz48cGF0aCBkPSJNMTkgMTZ2NiIgLz48L3N2Zz4=")} +.pt-i-report{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDN2MThoMTgiIC8+PHBhdGggZD0iTTIwIDE4djMiIC8+PHBhdGggZD0iTTE2IDE2djUiIC8+PHBhdGggZD0iTTEyIDEzdjgiIC8+PHBhdGggZD0iTTggMTZ2NSIgLz48cGF0aCBkPSJNMyAxMWM2IDAgNSAtNSA5IC01czMgNSA5IDUiIC8+PC9zdmc+")} +.pt-i-community{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNC43IDEzLjVjLTEuMSAtMiAtMS40NDEgLTIuNSAtMi43IC0yLjVjLTEuMjU5IDAgLTEuNzM2IC43NTUgLTIuODM2IDIuNzQ3Yy0uOTQyIDEuNzAzIC0yLjg0NiAxLjg0NSAtMy4zMjEgMy4yOTFjLS4wOTcgLjI2NSAtLjE0NSAuNjc3IC0uMTQzIC45NjJjMCAxLjE3NiAuNzg3IDIgMS44IDJjMS4yNTkgMCAzIC0xIDQuNSAtMXMzLjI0MSAxIDQuNSAxYzEuMDEzIDAgMS44IC0uODIzIDEuOCAtMmMwIC0uMjg1IC0uMDQ5IC0uNjk3IC0uMTQ2IC0uOTYyYy0uNDc1IC0xLjQ1MSAtMi41MTIgLTEuODM1IC0zLjQ1NCAtMy41MzgiIC8+PHBhdGggZD0iTTIwLjE4OCA4LjA4MmExLjAzOSAxLjAzOSAwIDAgMCAtLjQwNiAtLjA4MmgtLjAxNWMtLjczNSAuMDEyIC0xLjU2IC43NSAtMS45OTMgMS44NjZjLS41MTkgMS4zMzUgLS4yOCAyLjcgLjUzOCAzLjA1MmMuMTI5IC4wNTUgLjI2NyAuMDgyIC40MDYgLjA4MmMuNzM5IDAgMS41NzUgLS43NDIgMi4wMTEgLTEuODY2Yy41MTYgLTEuMzM1IC4yNzMgLTIuNyAtLjU0IC0zLjA1MmwtLjAwMSAwIiAvPjxwYXRoIGQ9Ik05LjQ3NCA5Yy4wNTUgMCAuMTA5IDAgLjE2MyAtLjAxMWMuOTQ0IC0uMTI4IDEuNTMzIC0xLjM0NiAxLjMyIC0yLjcyMmMtLjIwMyAtMS4yOTcgLTEuMDQ3IC0yLjI2NyAtMS45MzIgLTIuMjY3Yy0uMDU1IDAgLS4xMDkgMCAtLjE2MyAuMDExYy0uOTQ0IC4xMjggLTEuNTMzIDEuMzQ2IC0xLjMyIDIuNzIyYy4yMDQgMS4yOTMgMS4wNDggMi4yNjcgMS45MzMgMi4yNjciIC8+PHBhdGggZD0iTTE2LjQ1NiA2LjczM2MuMjE0IC0xLjM3NiAtLjM3NSAtMi41OTQgLTEuMzIgLTIuNzIyYTEuMTY0IDEuMTY0IDAgMCAwIC0uMTYyIC0uMDExYy0uODg1IDAgLTEuNzI4IC45NyAtMS45MyAyLjI2N2MtLjIxNCAxLjM3NiAuMzc1IDIuNTk0IDEuMzIgMi43MjJjLjA1NCAuMDA3IC4xMDggLjAxMSAuMTYyIC4wMTFjLjg4NSAwIDEuNzMgLS45NzQgMS45MyAtMi4yNjciIC8+PHBhdGggZD0iTTUuNjkgMTIuOTE4Yy44MTYgLS4zNTIgMS4wNTQgLTEuNzE5IC41MzYgLTMuMDUyYy0uNDM2IC0xLjEyNCAtMS4yNzEgLTEuODY2IC0yLjAwOSAtMS44NjZjLS4xNCAwIC0uMjc3IC4wMjcgLS40MDcgLjA4MmMtLjgxNiAuMzUyIC0xLjA1NCAxLjcxOSAtLjUzNiAzLjA1MmMuNDM2IDEuMTI0IDEuMjcxIDEuODY2IDIuMDA5IDEuODY2Yy4xNCAwIC4yNzcgLS4wMjcgLjQwNyAtLjA4MiIgLz48L3N2Zz4=")} +.pt-i-back{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNSA2bC02IDZsNiA2IiAvPjwvc3ZnPg==")} +.pt-i-next{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik05IDZsNiA2bC02IDYiIC8+PC9zdmc+")} +.pt-i-plus{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiA1bDAgMTQiIC8+PHBhdGggZD0iTTUgMTJsMTQgMCIgLz48L3N2Zz4=")} +.pt-i-check{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDEybDUgNWwxMCAtMTAiIC8+PC9zdmc+")} +.pt-i-close{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xOCA2bC0xMiAxMiIgLz48cGF0aCBkPSJNNiA2bDEyIDEyIiAvPjwvc3ZnPg==")} +.pt-i-trash{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDdsMTYgMCIgLz48cGF0aCBkPSJNMTAgMTFsMCA2IiAvPjxwYXRoIGQ9Ik0xNCAxMWwwIDYiIC8+PHBhdGggZD0iTTUgN2wxIDEyYTIgMiAwIDAgMCAyIDJoOGEyIDIgMCAwIDAgMiAtMmwxIC0xMiIgLz48cGF0aCBkPSJNOSA3di0zYTEgMSAwIDAgMSAxIC0xaDRhMSAxIDAgMCAxIDEgMXYzIiAvPjwvc3ZnPg==")} +.pt-i-edit{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDIwaDRsMTAuNSAtMTAuNWEyLjgyOCAyLjgyOCAwIDEgMCAtNCAtNGwtMTAuNSAxMC41djQiIC8+PHBhdGggZD0iTTEzLjUgNi41bDQgNCIgLz48L3N2Zz4=")} +.pt-i-share{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMyA0djRjLTYuNTc1IDEuMDI4IC05LjAyIDYuNzg4IC0xMCAxMmMtLjAzNyAuMjA2IDUuMzg0IC01Ljk2MiAxMCAtNnY0bDggLTdsLTggLTciIC8+PC9zdmc+")} +.pt-i-comment{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDIwbDEuMyAtMy45Yy0yLjMyNCAtMy40MzcgLTEuNDI2IC03Ljg3MiAyLjEgLTEwLjM3NGMzLjUyNiAtMi41MDEgOC41OSAtMi4yOTYgMTEuODQ1IC40OGMzLjI1NSAyLjc3NyAzLjY5NSA3LjI2NiAxLjAyOSAxMC41MDFjLTIuNjY2IDMuMjM1IC03LjYxNSA0LjIxNSAtMTEuNTc0IDIuMjkzbC00LjcgMSIgLz48L3N2Zz4=")} +.pt-i-like{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xOS41IDEyLjU3MmwtNy41IDcuNDI4bC03LjUgLTcuNDI4YTUgNSAwIDEgMSA3LjUgLTYuNTY2YTUgNSAwIDEgMSA3LjUgNi41NzIiIC8+PC9zdmc+")} +.pt-i-like-on{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMCIgPjxwYXRoIGQ9Ik02Ljk3OSAzLjA3NGE2IDYgMCAwIDEgNC45ODggMS40MjVsLjAzNyAuMDMzbC4wMzQgLS4wM2E2IDYgMCAwIDEgNC43MzMgLTEuNDRsLjI0NiAuMDM2YTYgNiAwIDAgMSAzLjM2NCAxMC4wMDhsLS4xOCAuMTg1bC0uMDQ4IC4wNDFsLTcuNDUgNy4zNzlhMSAxIDAgMCAxIC0xLjMxMyAuMDgybC0uMDk0IC0uMDgybC03LjQ5MyAtNy40MjJhNiA2IDAgMCAxIDMuMTc2IC0xMC4yMTV6IiAvPjwvc3ZnPg==")} +.pt-i-more{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDEyYTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PHBhdGggZD0iTTExIDEyYTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PHBhdGggZD0iTTE4IDEyYTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PC9zdmc+")} +.pt-i-bell{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMCA1YTIgMiAwIDEgMSA0IDBhNyA3IDAgMCAxIDQgNnYzYTQgNCAwIDAgMCAyIDNoLTE2YTQgNCAwIDAgMCAyIC0zdi0zYTcgNyAwIDAgMSA0IC02IiAvPjxwYXRoIGQ9Ik05IDE3djFhMyAzIDAgMCAwIDYgMHYtMSIgLz48L3N2Zz4=")} +.pt-i-settings{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMC4zMjUgNC4zMTdjLjQyNiAtMS43NTYgMi45MjQgLTEuNzU2IDMuMzUgMGExLjcyNCAxLjcyNCAwIDAgMCAyLjU3MyAxLjA2NmMxLjU0MyAtLjk0IDMuMzEgLjgyNiAyLjM3IDIuMzdhMS43MjQgMS43MjQgMCAwIDAgMS4wNjUgMi41NzJjMS43NTYgLjQyNiAxLjc1NiAyLjkyNCAwIDMuMzVhMS43MjQgMS43MjQgMCAwIDAgLTEuMDY2IDIuNTczYy45NCAxLjU0MyAtLjgyNiAzLjMxIC0yLjM3IDIuMzdhMS43MjQgMS43MjQgMCAwIDAgLTIuNTcyIDEuMDY1Yy0uNDI2IDEuNzU2IC0yLjkyNCAxLjc1NiAtMy4zNSAwYTEuNzI0IDEuNzI0IDAgMCAwIC0yLjU3MyAtMS4wNjZjLTEuNTQzIC45NCAtMy4zMSAtLjgyNiAtMi4zNyAtMi4zN2ExLjcyNCAxLjcyNCAwIDAgMCAtMS4wNjUgLTIuNTcyYy0xLjc1NiAtLjQyNiAtMS43NTYgLTIuOTI0IDAgLTMuMzVhMS43MjQgMS43MjQgMCAwIDAgMS4wNjYgLTIuNTczYy0uOTQgLTEuNTQzIC44MjYgLTMuMzEgMi4zNyAtMi4zN2MxIC42MDggMi4yOTYgLjA3IDIuNTcyIC0xLjA2NSIgLz48cGF0aCBkPSJNOSAxMmEzIDMgMCAxIDAgNiAwYTMgMyAwIDAgMCAtNiAwIiAvPjwvc3ZnPg==")} +.pt-i-ai{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNiAxOGEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0wIC0xMmEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0tNyAxMmE2IDYgMCAwIDEgNiAtNmE2IDYgMCAwIDEgLTYgLTZhNiA2IDAgMCAxIC02IDZhNiA2IDAgMCAxIDYgNiIgLz48L3N2Zz4=")} +.pt-i-warn{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiA5djQiIC8+PHBhdGggZD0iTTEwLjM2MyAzLjU5MWwtOC4xMDYgMTMuNTM0YTEuOTE0IDEuOTE0IDAgMCAwIDEuNjM2IDIuODcxaDE2LjIxNGExLjkxNCAxLjkxNCAwIDAgMCAxLjYzNiAtMi44N2wtOC4xMDYgLTEzLjUzNmExLjkxNCAxLjkxNCAwIDAgMCAtMy4yNzQgMCIgLz48cGF0aCBkPSJNMTIgMTZoLjAxIiAvPjwvc3ZnPg==")} +.pt-i-doc{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNCAzdjRhMSAxIDAgMCAwIDEgMWg0IiAvPjxwYXRoIGQ9Ik0xNyAyMWgtMTBhMiAyIDAgMCAxIC0yIC0ydi0xNGEyIDIgMCAwIDEgMiAtMmg3bDUgNXYxMWEyIDIgMCAwIDEgLTIgMiIgLz48cGF0aCBkPSJNOSA5bDEgMCIgLz48cGF0aCBkPSJNOSAxM2w2IDAiIC8+PHBhdGggZD0iTTkgMTdsNiAwIiAvPjwvc3ZnPg==")} +.pt-i-note{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDVhMiAyIDAgMCAxIDIgLTJoMTBhMiAyIDAgMCAxIDIgMnYxNGEyIDIgMCAwIDEgLTIgMmgtMTBhMiAyIDAgMCAxIC0yIC0ybDAgLTE0IiAvPjxwYXRoIGQ9Ik05IDdsNiAwIiAvPjxwYXRoIGQ9Ik05IDExbDYgMCIgLz48cGF0aCBkPSJNOSAxNWw0IDAiIC8+PC9zdmc+")} +.pt-i-book{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDE5YTkgOSAwIDAgMSA5IDBhOSA5IDAgMCAxIDkgMCIgLz48cGF0aCBkPSJNMyA2YTkgOSAwIDAgMSA5IDBhOSA5IDAgMCAxIDkgMCIgLz48cGF0aCBkPSJNMyA2bDAgMTMiIC8+PHBhdGggZD0iTTEyIDZsMCAxMyIgLz48cGF0aCBkPSJNMjEgNmwwIDEzIiAvPjwvc3ZnPg==")} +.pt-i-trend{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDE5bDE2IDAiIC8+PHBhdGggZD0iTTQgMTVsNCAtNmw0IDJsNCAtNWw0IDQiIC8+PC9zdmc+")} +.pt-i-cat{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0yMCAzdjEwYTggOCAwIDEgMSAtMTYgMHYtMTBsMy40MzIgMy40MzJhNy45NjMgNy45NjMgMCAwIDEgNC41NjggLTEuNDMyYzEuNzY5IDAgMy40MDMgLjU3NCA0LjcyOCAxLjU0NmwzLjI3MiAtMy41NDYiIC8+PHBhdGggZD0iTTIgMTZoNWwtNCA0IiAvPjxwYXRoIGQ9Ik0yMiAxNmgtNWw0IDQiIC8+PHBhdGggZD0iTTExIDE2YTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PHBhdGggZD0iTTkgMTF2LjAxIiAvPjxwYXRoIGQ9Ik0xNSAxMXYuMDEiIC8+PC9zdmc+")} +.pt-i-dog{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMSA1aDIiIC8+PHBhdGggZD0iTTE5IDEyYy0uNjY3IDUuMzMzIC0yLjMzMyA4IC01IDhoLTRjLTIuNjY3IDAgLTQuMzMzIC0yLjY2NyAtNSAtOCIgLz48cGF0aCBkPSJNMTEgMTZjMCAuNjY3IC4zMzMgMSAxIDFzMSAtLjMzMyAxIC0xaC0yIiAvPjxwYXRoIGQ9Ik0xMiAxOHYyIiAvPjxwYXRoIGQ9Ik0xMCAxMXYuMDEiIC8+PHBhdGggZD0iTTE0IDExdi4wMSIgLz48cGF0aCBkPSJNNSA0bDYgLjk3bC02LjIzOCA2LjY4OGExLjAyMSAxLjAyMSAwIDAgMSAtMS40MSAuMTExYS45NTMgLjk1MyAwIDAgMSAtLjMyNyAtLjk1NGwxLjk3NSAtNi44MTUiIC8+PHBhdGggZD0iTTE5IDRsLTYgLjk3bDYuMjM4IDYuNjg4Yy4zNTggLjQwOCAuOTg5IC40NTggMS40MSAuMTExYS45NTMgLjk1MyAwIDAgMCAuMzI3IC0uOTU0bC0xLjk3NSAtNi44MTUiIC8+PC9zdmc+")} +.pt-i-user{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik04IDdhNCA0IDAgMSAwIDggMGE0IDQgMCAwIDAgLTggMCIgLz48cGF0aCBkPSJNNiAyMXYtMmE0IDQgMCAwIDEgNCAtNGg0YTQgNCAwIDAgMSA0IDR2MiIgLz48L3N2Zz4=")} +.pt-i-clock{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDEyYTkgOSAwIDEgMCAxOCAwYTkgOSAwIDAgMCAtMTggMCIgLz48cGF0aCBkPSJNMTIgN3Y1bDMgMyIgLz48L3N2Zz4=")} +.pt-i-up{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDE1bDYgLTZsNiA2IiAvPjwvc3ZnPg==")} +.pt-i-down{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDlsNiA2bDYgLTYiIC8+PC9zdmc+")} +.pt-i-water{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik03LjUwMiAxOS40MjNjMi42MDIgMi4xMDUgNi4zOTUgMi4xMDUgOC45OTYgMGMyLjYwMiAtMi4xMDUgMy4yNjIgLTUuNzA4IDEuNTY2IC04LjU0NmwtNC44OSAtNy4yNmMtLjQyIC0uNjI1IC0xLjI4NyAtLjgwMyAtMS45MzYgLS4zOTdhMS4zNzYgMS4zNzYgMCAwIDAgLS40MSAuMzk3bC00Ljg5MyA3LjI2Yy0xLjY5NSAyLjgzOCAtMS4wMzUgNi40NDEgMS41NjcgOC41NDYiIC8+PC9zdmc+")} +.pt-i-bath{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDEyaDE2YTEgMSAwIDAgMSAxIDF2M2E0IDQgMCAwIDEgLTQgNGgtMTBhNCA0IDAgMCAxIC00IC00di0zYTEgMSAwIDAgMSAxIC0xIiAvPiA8cGF0aCBkPSJNNiAxMnYtN2EyIDIgMCAwIDEgMiAtMmgzdjIuMjUiIC8+IDxwYXRoIGQ9Ik00IDIxbDEgLTEuNSIgLz4gPHBhdGggZD0iTTIwIDIxbC0xIC0xLjUiIC8+PC9zdmc+")} +.pt-i-nail{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDdhMyAzIDAgMSAwIDYgMGEzIDMgMCAxIDAgLTYgMCIgLz4gPHBhdGggZD0iTTMgMTdhMyAzIDAgMSAwIDYgMGEzIDMgMCAxIDAgLTYgMCIgLz4gPHBhdGggZD0iTTguNiA4LjZsMTAuNCAxMC40IiAvPiA8cGF0aCBkPSJNOC42IDE1LjRsMTAuNCAtMTAuNCIgLz48L3N2Zz4=")} +.pt-i-ear{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDEwYTcgNyAwIDEgMSAxMyAzLjZhMTAgMTAgMCAwIDEgLTIgMmE4IDggMCAwIDAgLTIgM2E0LjUgNC41IDAgMCAxIC02LjggMS40IiAvPiA8cGF0aCBkPSJNMTAgMTBhMyAzIDAgMSAxIDUgMi4yIiAvPjwvc3ZnPg==")} +.pt-i-tooth{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiA1LjVjLTEuMDc0IC0uNTg2IC0yLjU4MyAtMS41IC00IC0xLjVjLTIuMSAwIC00IDEuMjQ3IC00IDVjMCA0Ljg5OSAxLjA1NiA4LjQxIDIuNjcxIDEwLjUzN2MuNTczIC43NTYgMS45NyAuNTIxIDIuNTY3IC0uMjM2Yy4zOTggLS41MDUgLjgxOSAtMS40MzkgMS4yNjIgLTIuODAxYy4yOTIgLS43NzEgLjg5MiAtMS41MDQgMS41IC0xLjVjLjYwMiAwIDEuMjEgLjczNyAxLjUgMS41Yy40NDMgMS4zNjIgLjg2NCAyLjI5NSAxLjI2MiAyLjhjLjU5NyAuNzU5IDIgLjk5MyAyLjU2NyAuMjM3YzEuNjE1IC0yLjEyNyAyLjY3MSAtNS42MzcgMi42NzEgLTEwLjUzN2MwIC0zLjc0IC0xLjkwOCAtNSAtNCAtNWMtMS40MjMgMCAtMi45MiAuOTExIC00IDEuNSIgLz4gPHBhdGggZD0iTTEyIDUuNWwzIDEuNSIgLz48L3N2Zz4=")} +.pt-i-brush{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDIxdi00YTQgNCAwIDEgMSA0IDRoLTQiIC8+IDxwYXRoIGQ9Ik0yMSAzYTE2IDE2IDAgMCAwIC0xMi44IDEwLjIiIC8+IDxwYXRoIGQ9Ik0yMSAzYTE2IDE2IDAgMCAxIC0xMC4yIDEyLjgiIC8+IDxwYXRoIGQ9Ik0xMC42IDlhOSA5IDAgMCAxIDQuNCA0LjQiIC8+PC9zdmc+")} +.pt-i-groom{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNiAxOGEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0wIC0xMmEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0tNyAxMmE2IDYgMCAwIDEgNiAtNmE2IDYgMCAwIDEgLTYgLTZhNiA2IDAgMCAxIC02IDZhNiA2IDAgMCAxIDYgNiIgLz48L3N2Zz4=")} +.pt-i-litter{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDdhOCA0IDAgMSAwIDE2IDBhOCA0IDAgMSAwIC0xNiAwIiAvPiA8cGF0aCBkPSJNNCA3YzAgLjY2NCAuMDg4IDEuMzI0IC4yNjMgMS45NjVsMi43MzcgMTAuMDM1Yy41IDEuNSAyLjIzOSAyIDUgMnM0LjUgLS41IDUgLTJjLjMzMyAtMSAxLjI0NiAtNC4zNDUgMi43MzcgLTEwLjAzNWE3LjQ1IDcuNDUgMCAwIDAgLjI2MyAtMS45NjUiIC8+PC9zdmc+")} +.pt-i-litterbox{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDE2bDEuNDY1IDEuNjM4YTIgMiAwIDEgMSAtMy4wMTUgLjA5OWwxLjU1IC0xLjczNyIgLz4gPHBhdGggZD0iTTEzLjczNyA5LjczN2MyLjI5OSAtMi4zIDMuMjMgLTUuMDk1IDIuMDgxIC02LjI0NWMtMS4xNSAtMS4xNSAtMy45NDUgLS4yMTcgLTYuMjQ0IDIuMDgyYy0yLjMgMi4yOTkgLTMuMjMxIDUuMDk1IC0yLjA4MiA2LjI0NGMxLjE1IDEuMTUgMy45NDYgLjIxOCA2LjI0NSAtMi4wODEiIC8+IDxwYXRoIGQ9Ik03LjQ5MiAxMS44MThjLjM2MiAuMzYyIC43NjggLjY3NiAxLjIwOCAuOTM0bDYuODk1IDQuMDQ3YzEuMDc4IC41NTcgMi4yNTUgLS4wNzUgMy42OTIgLTEuNTEyYzEuNDM3IC0xLjQzNyAyLjA3IC0yLjYxNCAxLjUxMiAtMy42OTJjLS4zNzIgLS43MTggLTEuNzIgLTMuMDE3IC00LjA0NyAtNi44OTVhNi4wMTUgNi4wMTUgMCAwIDAgLS45MzQgLTEuMjA4IiAvPjwvc3ZnPg==")} +.pt-i-bowl{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDhoMTZhMSAxIDAgMCAxIDEgMXYuNWMwIDEuNSAtMi41MTcgNS41NzMgLTQgNi41djFhMSAxIDAgMCAxIC0xIDFoLThhMSAxIDAgMCAxIC0xIC0xdi0xYy0xLjY4NyAtMS4wNTQgLTQgLTUgLTQgLTYuNXYtLjVhMSAxIDAgMCAxIDEgLTEiIC8+PC9zdmc+")} +.pt-i-waterbowl{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik04IDIxbDggMCIgLz4gPHBhdGggZD0iTTEyIDE1bDAgNiIgLz4gPHBhdGggZD0iTTE3IDNsMSA3YzAgMy4wMTIgLTIuNjg2IDUgLTYgNXMtNiAtMS45ODggLTYgLTVsMSAtN2gxMCIgLz4gPHBhdGggZD0iTTYgMTBhNSA1IDAgMCAxIDYgMGE1IDUgMCAwIDAgNiAwIiAvPjwvc3ZnPg==")} +.pt-i-clean{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDEyYTIgMiAwIDAgMSAyIC0yaDRhMiAyIDAgMCAxIDIgMnY3YTIgMiAwIDAgMSAtMiAyaC00YTIgMiAwIDAgMSAtMiAtMmwwIC03IiAvPiA8cGF0aCBkPSJNNiAxMHYtNGExIDEgMCAwIDEgMSAtMWgyYTEgMSAwIDAgMSAxIDF2NCIgLz4gPHBhdGggZD0iTTE1IDdoLjAxIiAvPiA8cGF0aCBkPSJNMTggOWguMDEiIC8+IDxwYXRoIGQ9Ik0xOCA1aC4wMSIgLz4gPHBhdGggZD0iTTIxIDNoLjAxIiAvPiA8cGF0aCBkPSJNMjEgN2guMDEiIC8+IDxwYXRoIGQ9Ik0yMSAxMWguMDEiIC8+IDxwYXRoIGQ9Ik0xMCA3aDEiIC8+PC9zdmc+")} +.pt-i-checkup{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDRoLTFhMiAyIDAgMCAwIC0yIDJ2My41YTUuNSA1LjUgMCAwIDAgMTEgMHYtMy41YTIgMiAwIDAgMCAtMiAtMmgtMSIgLz4gPHBhdGggZD0iTTggMTVhNiA2IDAgMSAwIDEyIDB2LTMiIC8+IDxwYXRoIGQ9Ik0xMSAzdjIiIC8+IDxwYXRoIGQ9Ik02IDN2MiIgLz4gPHBhdGggZD0iTTE4IDEwYTIgMiAwIDEgMCA0IDBhMiAyIDAgMSAwIC00IDAiIC8+PC9zdmc+")} +.pt-i-vet{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik04IDh2LTJhMiAyIDAgMCAxIDIgLTJoNGEyIDIgMCAwIDEgMiAydjIiIC8+IDxwYXRoIGQ9Ik00IDEwYTIgMiAwIDAgMSAyIC0yaDEyYTIgMiAwIDAgMSAyIDJ2OGEyIDIgMCAwIDEgLTIgMmgtMTJhMiAyIDAgMCAxIC0yIC0ybDAgLTgiIC8+IDxwYXRoIGQ9Ik0xMCAxNGg0IiAvPiA8cGF0aCBkPSJNMTIgMTJ2NCIgLz48L3N2Zz4=")} +.pt-i-supplement{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00LjUgMTIuNWw4IC04YTQuOTQgNC45NCAwIDAgMSA3IDdsLTggOGE0Ljk0IDQuOTQgMCAwIDEgLTcgLTciIC8+IDxwYXRoIGQ9Ik04LjUgOC41bDcgNyIgLz48L3N2Zz4=")} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index f7b1ee5..20461f5 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -1,4 +1,11 @@ -const { request, uploadFile, setToken } = require('./request.js'); +const { request, uploadFile, setToken, setRefreshToken } = require('./request.js'); + +// 登录成功后统一落盘 access + refresh 两个令牌 +function saveTokens(data) { + setToken(data.token); + setRefreshToken(data.refresh_token); + return data; +} // wx.login 取 code function wxLogin() { @@ -14,14 +21,18 @@ function wxLogin() { async function login() { try { const code = await wxLogin(); - const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code } }); - setToken(data.token); - return data; + // noAuthRetry:登录接口失败时也会返回 40100,不能再触发「续期→重新登录」,否则会自己套自己 + const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code }, noAuthRetry: true }); + return saveTokens(data); } catch (e) { console.warn('[api] 微信登录失败,回退 Mock 登录:', e && e.message); - const data = await request({ url: '/api/auth/login', method: 'POST', data: { nickname: '开发用户' } }); - setToken(data.token); - return data; + const data = await request({ + url: '/api/auth/login', + method: 'POST', + data: { nickname: '开发用户' }, + noAuthRetry: true, + }); + return saveTokens(data); } } @@ -35,11 +46,30 @@ const api = { // 用户 userSummary: () => request({ url: '/api/user/summary' }), + // 记录类型(后台可配)。分组好的,前端直接渲染 + recordTypes: () => request({ url: '/api/record-types' }), + updateDecoration: (body) => request({ url: '/api/user/decoration', method: 'PUT', data: body }), // 宠物 getPets: () => request({ url: '/api/pets' }), homeSummary: (id) => request({ url: `/api/pets/${id}/home-summary` }), getPet: (id) => request({ url: `/api/pets/${id}` }), + // 养护方案:可选列表 + 套用到某个月 + careTemplates: (species, stage) => + request({ url: '/api/care-templates?species=' + species + '&stage=' + encodeURIComponent(stage) }), + applyTemplate: (petId, templateID, monthOffset) => + request({ url: `/api/pets/${petId}/apply-template`, method: 'POST', + data: { template_id: templateID, month_offset: monthOffset || 0 } }), + + // 生命阶段的划分标准。判定和展示用同一份数据 —— 不另写一份说明文案, + // 否则改了阈值忘了改文案,用户看到的依据和实际行为对不上 + lifeStages: (species, size) => + request({ url: `/api/life-stages?species=${species}&size=${size || ''}` }), + petIDCard: (id) => request({ url: `/api/pets/${id}/id-card` }), + petStageDrift: (petId) => request({ url: `/api/pets/${petId}/stage-drift` }), + + // 品种(后台可配),按首字母分组 + breeds: (species) => request({ url: '/api/breeds?species=' + species }), createPet: (body) => request({ url: '/api/pets', method: 'POST', data: body }), updatePet: (id, body) => request({ url: `/api/pets/${id}`, method: 'PUT', data: body }), deletePet: (id) => request({ url: `/api/pets/${id}`, method: 'DELETE' }), @@ -55,6 +85,7 @@ const api = { }, createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }), deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }), + petInsights: (id) => request({ url: `/api/pets/${id}/insights` }), weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }), // 任务 @@ -70,8 +101,15 @@ const api = { planCalendar: (id, month) => request({ url: `/api/pets/${id}/plan/calendar${month ? `?month=${month}` : ''}` }), dayPlan: (id, date) => request({ url: `/api/pets/${id}/day-plan?date=${date}` }), - createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }), - applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }), + // AI 相关:用户端入口已全部下掉,后端 10 个路由和配额配置都还在。 + // 想开回来把这几行解注释、再把入口接上就行,不用重写。 + // createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }), + // applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }), + // 用户自己管理计划节点 + planMonthStatus: (petId) => request({ url: `/api/pets/${petId}/plan/month-status` }), + addPlanTask: (petId, body) => request({ url: `/api/pets/${petId}/plan-tasks`, method: 'POST', data: body }), + updatePlanTask: (taskId, body) => request({ url: `/api/plan-tasks/${taskId}`, method: 'PUT', data: body }), + deletePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}`, method: 'DELETE' }), togglePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}/toggle`, method: 'POST' }), // 提醒 @@ -91,13 +129,22 @@ const api = { request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }), createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }), likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }), + userCard: (userId) => request({ url: `/api/users/${userId}/profile` }), + userPosts: (userId, page) => request({ url: `/api/users/${userId}/posts?page=${page || 1}&page_size=10` }), + relations: (userId, kind, page) => + request({ url: `/api/users/${userId}/relations?kind=${kind}&page=${page || 1}&page_size=20` }), followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }), unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }), deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }), deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }), listComments: (id) => request({ url: `/api/posts/${id}/comments` }), - createComment: (id, content) => - request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }), + createComment: (id, content, images, parentId) => + request({ + url: `/api/posts/${id}/comments`, + method: 'POST', + data: { content, images: images || [], parent_id: parentId || '' }, + }), + listReplies: (commentId) => request({ url: `/api/comments/${commentId}/replies` }), // 文章 listArticles: () => request({ url: '/api/articles' }), @@ -109,11 +156,11 @@ const api = { activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }), // AI - aiMessages: (session, limit) => - request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }), - aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }), - assessSymptom: (id, body) => - request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }), + // aiMessages: (session, limit) => + // request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }), + // aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }), + // assessSymptom: (id, body) => + // request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }), }; module.exports = api; diff --git a/pets-fe/utils/petmeta.js b/pets-fe/utils/petmeta.js new file mode 100644 index 0000000..3a3c7aa --- /dev/null +++ b/pets-fe/utils/petmeta.js @@ -0,0 +1,45 @@ +// 宠物档案里那些「有数据但没露出」的东西。字段后端早就返回了,只是没人用。 +// 单独放一个文件而不是塞在 home.js 里:身份卡、宠友主页以后都要用同一套算法。 + +// 星座。按公历日期分段,边界日取「这一天起算下一个星座」 +const ZODIAC = [ + [1, 20, '水瓶座'], [2, 19, '双鱼座'], [3, 21, '白羊座'], [4, 20, '金牛座'], + [5, 21, '双子座'], [6, 22, '巨蟹座'], [7, 23, '狮子座'], [8, 23, '处女座'], + [9, 23, '天秤座'], [10, 24, '天蝎座'], [11, 23, '射手座'], [12, 22, '摩羯座'], +]; +function zodiac(birthday) { + if (!birthday) return ''; + const p = String(birthday).slice(0, 10).split('-'); + if (p.length < 3) return ''; + const m = +p[1], d = +p[2]; + if (!m || !d) return ''; + // 12 月 22 日之后到 1 月 19 日都是摩羯 + for (let i = 0; i < ZODIAC.length; i++) { + const [zm, zd, name] = ZODIAC[i]; + if (m === zm) return d >= zd ? name : ZODIAC[(i + 11) % 12][2]; + } + return ''; +} + +// 一起生活了多少天。优先用到家日期——「我们一起生活的第 N 天」说的是 +// 它到你家之后的日子,不是它出生之后的日子。没填到家日就退回建档日。 +// 一起生活了多少天,只从「到家日期」算 —— 没填到家日就返回 null(首页显示「-」)。 +// 原来会退回建档日兜底,但那是「在这个 App 上建档的日子」,不是「它到你家的日子」, +// 领养多年后才装 App 的会算出个很小的数,反而误导 +function daysTogether(pet) { + const from = (pet && pet.arrived_at) || ''; + if (!from) return null; + const d = new Date(String(from).slice(0, 10) + 'T00:00:00'); + if (isNaN(d.getTime())) return null; + const days = Math.floor((Date.now() - d.getTime()) / 86400000) + 1; + return days > 0 ? days : null; +} + +// 性别徽章。后端存的是「男孩 / 女孩 / 不确定」 +function genderBadge(gender) { + if (gender === '男孩') return { icon: '♂', tone: 'male' }; + if (gender === '女孩') return { icon: '♀', tone: 'female' }; + return { icon: '', tone: '' }; +} + +module.exports = { zodiac, daysTogether, genderBadge }; diff --git a/pets-fe/utils/recordTypes.js b/pets-fe/utils/recordTypes.js new file mode 100644 index 0000000..e0c16a9 --- /dev/null +++ b/pets-fe/utils/recordTypes.js @@ -0,0 +1,78 @@ +// 记录类型缓存。类型从后端来(后台可配),记录页要拿它渲染分组宫格, +// 弹层要拿它判断走专用表单还是通用简易表单。两边必须是同一份数据—— +// 各自请求一次的话,后台改完配置两边会短暂不一致。 +const api = require('./api.js'); + +// 四个分组对应的色系。这是纯表现层的东西,不进后端配置: +// 后端存 group key,色值留在前端,改配色不用动数据 +const GROUP_TONE = { + daily: 'tone-1', + health: 'tone-2', + care: 'tone-4', + clean: 'tone-3', +}; + +let groups = null; // [{ key, label, items: [...] }] +let byCode = {}; +let loading = null; + +function index(gs) { + groups = (gs || []).map((g) => ({ + ...g, + tone: GROUP_TONE[g.key] || 'tone-1', + items: (g.items || []).map((t) => ({ ...t, tone: GROUP_TONE[g.key] || 'tone-1' })), + })); + byCode = {}; + groups.forEach((g) => g.items.forEach((t) => (byCode[t.code] = t))); + return groups; +} + +// load 只真正请求一次;并发调用共用同一个 promise +function load() { + if (groups) return Promise.resolve(groups); + if (loading) return loading; + loading = api + .recordTypes() + .then((gs) => { + loading = null; + return index(gs); + }) + .catch((e) => { + loading = null; + throw e; + }); + return loading; +} + +// get 是同步的,给弹层用——弹层打开时不该再等一次网络请求。 +// 记录页/首页先 load 过,缓存已经热了;万一没热,返回 undefined, +// 调用方按「专用表单」处理(那 9 种的分支是写死在 wxml 里的,不依赖这份缓存) +function get(code) { + return byCode[code]; +} + +// isSimple 走通用简易表单的类型。缓存没热时返回 false, +// 宁可打不开也不要用错的表单存下一条字段不全的记录 +function isSimple(code) { + const t = byCode[code]; + return !!t && t.form === 'simple'; +} + +function label(code) { + const t = byCode[code]; + return (t && t.label) || ''; +} + +// 后台改了配置之后强制重取 +function reset() { + groups = null; + byCode = {}; + loading = null; +} + +// groups 同步读缓存,给「任务关联记录类型」那个下拉用 +function all() { + return groups; +} + +module.exports = { load, get, isSimple, label, reset, groups: all, GROUP_TONE }; diff --git a/pets-fe/utils/request.js b/pets-fe/utils/request.js index 609367e..bbfe6a5 100644 --- a/pets-fe/utils/request.js +++ b/pets-fe/utils/request.js @@ -1,6 +1,7 @@ const { BASE_URL } = require('./config.js'); const TOKEN_KEY = 'pets_token'; +const REFRESH_KEY = 'pets_refresh_token'; function getToken() { return wx.getStorageSync(TOKEN_KEY) || ''; @@ -8,8 +9,15 @@ function getToken() { function setToken(t) { wx.setStorageSync(TOKEN_KEY, t); } +function getRefreshToken() { + return wx.getStorageSync(REFRESH_KEY) || ''; +} +function setRefreshToken(t) { + if (t) wx.setStorageSync(REFRESH_KEY, t); +} function clearToken() { wx.removeStorageSync(TOKEN_KEY); + wx.removeStorageSync(REFRESH_KEY); } // token 失效时的重新登录钩子,由 store 注册(放在这里避免 request ← api ← store 循环依赖) @@ -20,10 +28,43 @@ function setReAuthHandler(fn) { const TIMEOUT = 15000; +// access token 只有 2 小时,过期后先拿 refresh token 换新的(用户无感), +// 换不到(刷新令牌也过期/被吊销)才退回 wx.login 重新登录。 +// 单飞:并发请求同时 401 时只发一次刷新,否则刷新令牌会被并发轮换掉。 +let refreshing = null; + +function refreshAccessToken() { + if (!refreshing) { + const rt = getRefreshToken(); + refreshing = (rt + ? request({ url: '/api/auth/refresh', method: 'POST', data: { refresh_token: rt }, noAuthRetry: true }) + : Promise.reject(new Error('没有刷新令牌')) + ).then((data) => { + setToken(data.token); + setRefreshToken(data.refresh_token); + return data.token; + }); + const done = () => { + refreshing = null; + }; + refreshing.then(done, done); + } + return refreshing; +} + +// 恢复登录态:先续期,续不了就整套重新登录 +function recoverAuth() { + return refreshAccessToken().catch(() => { + clearToken(); + if (!reAuth) throw new Error('登录已过期'); + return reAuth(); + }); +} + // 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)。 -// token 失效(40100)时自动重新登录并重试一次,避免 JWT 过期后小程序静默白屏。 +// token 失效(40100)时自动续期/重新登录并重试一次,避免 JWT 过期后小程序静默白屏。 function request(options, _retried) { - const { url, method = 'GET', data, header = {} } = options; + const { url, method = 'GET', data, header = {}, noAuthRetry } = options; return new Promise((resolve, reject) => { const token = getToken(); wx.request({ @@ -41,13 +82,17 @@ function request(options, _retried) { if (body && typeof body === 'object' && 'code' in body) { if (body.code === 0) return resolve(body.data); if (body.code === 40100) { - clearToken(); - // 只重试一次,防止登录接口本身出问题时无限循环 - if (!_retried && reAuth) { - return reAuth() - .then(() => request(options, true).then(resolve, reject)) - .catch(() => reject(new Error(body.message || '登录已过期,请重进小程序'))); + // 只重试一次,防止刷新/登录接口本身出问题时无限循环 + if (_retried || noAuthRetry) { + clearToken(); + return reject(new Error(body.message || '登录已过期,请重进小程序')); } + return recoverAuth() + .then(() => request(options, true).then(resolve, reject)) + .catch(() => { + clearToken(); + reject(new Error(body.message || '登录已过期,请重进小程序')); + }); } return reject(new Error(body.message || '请求失败')); } @@ -61,8 +106,8 @@ function request(options, _retried) { }); } -// 文件上传(multipart) -function uploadFile(filePath) { +// 文件上传(multipart);token 过期同样先续期再重试一次 +function uploadFile(filePath, _retried) { return new Promise((resolve, reject) => { const token = getToken(); wx.uploadFile({ @@ -75,6 +120,11 @@ function uploadFile(filePath) { try { const body = JSON.parse(res.data); if (body.code === 0) return resolve(body.data); + if (body.code === 40100 && !_retried) { + return recoverAuth() + .then(() => uploadFile(filePath, true).then(resolve, reject)) + .catch(() => reject(new Error('登录已过期,请重进小程序'))); + } reject(new Error(body.message || '上传失败')); } catch (e) { reject(new Error('上传响应解析失败')); @@ -87,4 +137,13 @@ function uploadFile(filePath) { }); } -module.exports = { request, uploadFile, getToken, setToken, clearToken, setReAuthHandler }; +module.exports = { + request, + uploadFile, + getToken, + setToken, + getRefreshToken, + setRefreshToken, + clearToken, + setReAuthHandler, +}; diff --git a/pets-fe/utils/tabbar.js b/pets-fe/utils/tabbar.js new file mode 100644 index 0000000..6bea258 --- /dev/null +++ b/pets-fe/utils/tabbar.js @@ -0,0 +1,14 @@ +// tabBar 高亮同步。 +// +// 本来想让 custom-tab-bar 自己在 pageLifetimes.show 里算,页面一行都不用写。 +// 实测不行:自定义 tabBar 不在页面节点树里,pageLifetimes 拿不到可靠的时机, +// 结果是切到「我的」时高亮还留在「首页」。只能回到官方那套「页面 onShow 里推一把」。 +// +// 但索引仍然由 tabBar 按当前路由自己算,页面这边不写死 0/1/2/3/4—— +// 调 tab 顺序时改一个 LIST 就够了,五个页面一行都不用动。 +function syncTabBar(page) { + const bar = page.getTabBar && page.getTabBar(); + if (bar && bar.syncSelected) bar.syncSelected(); +} + +module.exports = { syncTabBar };