>>2]|=e<>>2]|=(192|e>>>6)<
>>2]|=(128|63&e)<
=57344?(a[i>>>2]|=(224|e>>>12)<
>>2]|=(128|e>>>6&63)<
>>2]|=(128|63&e)<
>>2]|=(240|e>>>18)<
>>2]|=(128|e>>>12&63)<
>>2]|=(128|e>>>6&63)<
>>2]|=(128|63&e)<
>>2]|=t[h]<
=64?(this.start=i-64,this.hash(),this.hashed=!0):this.start=i}return this.bytes>4294967295&&(this.hBytes+=this.bytes/4294967296<<0,this.bytes=this.bytes%4294967296),this},t.prototype.finalize=function(){if(!this.finalized){this.finalized=!0;var t=this.blocks,r=this.lastByteIndex;t[r>>>2]|=y[3&r],r>=56&&(this.hashed||this.hash(),t[0]=t[16],t[16]=t[1]=t[2]=t[3]=t[4]=t[5]=t[6]=t[7]=t[8]=t[9]=t[10]=t[11]=t[12]=t[13]=t[14]=t[15]=0),t[14]=this.bytes<<3,t[15]=this.hBytes<<3|this.bytes>>>29,this.hash()}},t.prototype.hash=function(){var t,r,e,i,s,h,n=this.blocks;this.first?r=((r=((t=((t=n[0]-680876937)<<7|t>>>25)-271733879<<0)^(e=((e=(-271733879^(i=((i=(-1732584194^2004318071&t)+n[1]-117830708)<<12|i>>>20)+t<<0)&(-271733879^t))+n[2]-1126478375)<<17|e>>>15)+i<<0)&(i^t))+n[3]-1316259209)<<22|r>>>10)+e<<0:(t=this.h0,r=this.h1,e=this.h2,r=((r+=((t=((t+=((i=this.h3)^r&(e^i))+n[0]-680876936)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[1]-389564586)<<12|i>>>20)+t<<0)&(t^r))+n[2]+606105819)<<17|e>>>15)+i<<0)&(i^t))+n[3]-1044525330)<<22|r>>>10)+e<<0),r=((r+=((t=((t+=(i^r&(e^i))+n[4]-176418897)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[5]+1200080426)<<12|i>>>20)+t<<0)&(t^r))+n[6]-1473231341)<<17|e>>>15)+i<<0)&(i^t))+n[7]-45705983)<<22|r>>>10)+e<<0,r=((r+=((t=((t+=(i^r&(e^i))+n[8]+1770035416)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[9]-1958414417)<<12|i>>>20)+t<<0)&(t^r))+n[10]-42063)<<17|e>>>15)+i<<0)&(i^t))+n[11]-1990404162)<<22|r>>>10)+e<<0,r=((r+=((t=((t+=(i^r&(e^i))+n[12]+1804603682)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[13]-40341101)<<12|i>>>20)+t<<0)&(t^r))+n[14]-1502002290)<<17|e>>>15)+i<<0)&(i^t))+n[15]+1236535329)<<22|r>>>10)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[1]-165796510)<<5|t>>>27)+r<<0)^r))+n[6]-1069501632)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[11]+643717713)<<14|e>>>18)+i<<0)^i))+n[0]-373897302)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[5]-701558691)<<5|t>>>27)+r<<0)^r))+n[10]+38016083)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[15]-660478335)<<14|e>>>18)+i<<0)^i))+n[4]-405537848)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[9]+568446438)<<5|t>>>27)+r<<0)^r))+n[14]-1019803690)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[3]-187363961)<<14|e>>>18)+i<<0)^i))+n[8]+1163531501)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[13]-1444681467)<<5|t>>>27)+r<<0)^r))+n[2]-51403784)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[7]+1735328473)<<14|e>>>18)+i<<0)^i))+n[12]-1926607734)<<20|r>>>12)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[5]-378558)<<4|t>>>28)+r<<0))+n[8]-2022574463)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[11]+1839030562)<<16|e>>>16)+i<<0))+n[14]-35309556)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[1]-1530992060)<<4|t>>>28)+r<<0))+n[4]+1272893353)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[7]-155497632)<<16|e>>>16)+i<<0))+n[10]-1094730640)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[13]+681279174)<<4|t>>>28)+r<<0))+n[0]-358537222)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[3]-722521979)<<16|e>>>16)+i<<0))+n[6]+76029189)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[9]-640364487)<<4|t>>>28)+r<<0))+n[12]-421815835)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[15]+530742520)<<16|e>>>16)+i<<0))+n[2]-995338651)<<23|r>>>9)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[0]-198630844)<<6|t>>>26)+r<<0)|~e))+n[7]+1126891415)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[14]-1416354905)<<15|e>>>17)+i<<0)|~t))+n[5]-57434055)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[12]+1700485571)<<6|t>>>26)+r<<0)|~e))+n[3]-1894986606)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[10]-1051523)<<15|e>>>17)+i<<0)|~t))+n[1]-2054922799)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[8]+1873313359)<<6|t>>>26)+r<<0)|~e))+n[15]-30611744)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[6]-1560198380)<<15|e>>>17)+i<<0)|~t))+n[13]+1309151649)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[4]-145523070)<<6|t>>>26)+r<<0)|~e))+n[11]-1120210379)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[2]+718787259)<<15|e>>>17)+i<<0)|~t))+n[9]-343485551)<<21|r>>>11)+e<<0,this.first?(this.h0=t+1732584193<<0,this.h1=r-271733879<<0,this.h2=e-1732584194<<0,this.h3=i+271733878<<0,this.first=!1):(this.h0=this.h0+t<<0,this.h1=this.h1+r<<0,this.h2=this.h2+e<<0,this.h3=this.h3+i<<0)},t.prototype.hex=function(){this.finalize();var t=this.h0,r=this.h1,e=this.h2,i=this.h3;return c[t>>>4&15]+c[15&t]+c[t>>>12&15]+c[t>>>8&15]+c[t>>>20&15]+c[t>>>16&15]+c[t>>>28&15]+c[t>>>24&15]+c[r>>>4&15]+c[15&r]+c[r>>>12&15]+c[r>>>8&15]+c[r>>>20&15]+c[r>>>16&15]+c[r>>>28&15]+c[r>>>24&15]+c[e>>>4&15]+c[15&e]+c[e>>>12&15]+c[e>>>8&15]+c[e>>>20&15]+c[e>>>16&15]+c[e>>>28&15]+c[e>>>24&15]+c[i>>>4&15]+c[15&i]+c[i>>>12&15]+c[i>>>8&15]+c[i>>>20&15]+c[i>>>16&15]+c[i>>>28&15]+c[i>>>24&15]},t.prototype.toString=t.prototype.hex,t.prototype.digest=function(){this.finalize();var t=this.h0,r=this.h1,e=this.h2,i=this.h3;return[255&t,t>>>8&255,t>>>16&255,t>>>24&255,255&r,r>>>8&255,r>>>16&255,r>>>24&255,255&e,e>>>8&255,e>>>16&255,e>>>24&255,255&i,i>>>8&255,i>>>16&255,i>>>24&255]},t.prototype.array=t.prototype.digest,t.prototype.arrayBuffer=function(){this.finalize();var t=new ArrayBuffer(16),r=new Uint32Array(t);return r[0]=this.h0,r[1]=this.h1,r[2]=this.h2,r[3]=this.h3,t},t.prototype.buffer=t.prototype.arrayBuffer,t.prototype.base64=function(){for(var t,r,e,i="",s=this.array(),h=0;h<15;)t=s[h++],r=s[h++],e=s[h++],i+=l[t>>>2]+l[63&(t<<4|r>>>4)]+l[63&(r<<2|e>>>6)]+l[63&e];return t=s[h],i+=l[t>>>2]+l[t<<4&63]+"=="},(r.prototype=new t).finalize=function(){if(t.prototype.finalize.call(this),this.inner){this.inner=!1;var r=this.array();t.call(this,this.sharedMemory),this.update(this.oKeyPad),this.update(r),t.prototype.finalize.call(this)}};var O=function(){var r=B("hex");n&&(r=g(r)),r.create=function(){return new t},r.update=function(t){return r.create().update(t)};for(var e=0;e 标签的浏览器(别名: esm,module)
+// umd - 通用模块定义,生成的包同时支持 amd、cjs 和 iife 三种格式
+//---------------------------------------------------------------------
+
+!function(o,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(o="undefined"!=typeof globalThis?globalThis:o||self).UQRCode=e();}(typeof window !== "undefined" ? window : global,(function(){function o(o){this.mode=r.MODE_8BIT_BYTE,this.data=o;}function e(o,e){this.typeNumber=o,this.errorCorrectLevel=e,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=new Array;}o.prototype={getLength:function(o){return this.data.length},write:function(o){for(var e=0;e=7&&this.setupTypeNumber(o),null==this.dataCache&&(this.dataCache=e.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,r);},setupPositionProbePattern:function(o,e){for(var r=-1;r<=7;r++)if(!(o+r<=-1||this.moduleCount<=o+r))for(var t=-1;t<=7;t++)e+t<=-1||this.moduleCount<=e+t||(this.modules[o+r][e+t]=0<=r&&r<=6&&(0==t||6==t)||0<=t&&t<=6&&(0==r||6==r)||2<=r&&r<=4&&2<=t&&t<=4);},getBestMaskPattern:function(){for(var o=0,e=0,r=0;r<8;r++){this.makeImpl(!0,r);var t=h.getLostPoint(this);(0==r||o>t)&&(o=t,e=r);}return e},createMovieClip:function(o,e,r){var t=o.createEmptyMovieClip(e,r);this.make();for(var i=0;i>r&1);this.modules[Math.floor(r/3)][r%3+this.moduleCount-8-3]=t;}for(r=0;r<18;r++){t=!o&&1==(e>>r&1);this.modules[r%3+this.moduleCount-8-3][Math.floor(r/3)]=t;}},setupTypeInfo:function(o,e){for(var r=this.errorCorrectLevel<<3|e,t=h.getBCHTypeInfo(r),i=0;i<15;i++){var n=!o&&1==(t>>i&1);i<6?this.modules[i][8]=n:i<8?this.modules[i+1][8]=n:this.modules[this.moduleCount-15+i][8]=n;}for(i=0;i<15;i++){n=!o&&1==(t>>i&1);i<8?this.modules[8][this.moduleCount-i-1]=n:i<9?this.modules[8][15-i-1+1]=n:this.modules[8][15-i-1]=n;}this.modules[this.moduleCount-8][8]=!o;},mapData:function(o,e){for(var r=-1,t=this.moduleCount-1,i=7,n=0,a=this.moduleCount-1;a>0;a-=2)for(6==a&&a--;;){for(var d=0;d<2;d++)if(null==this.modules[t][a-d]){var u=!1;n>>i&1)),h.getMask(e,t,a-d)&&(u=!u),this.modules[t][a-d]=u,-1==--i&&(n++,i=7);}if((t+=r)<0||this.moduleCount<=t){t-=r,r=-r;break}}}},e.PAD0=236,e.PAD1=17,e.createData=function(o,r,t){for(var i=v.getRSBlocks(o,r),n=new p,a=0;a8*u)throw new Error("code length overflow. ("+n.getLengthInBits()+">"+8*u+")");for(n.getLengthInBits()+4<=8*u&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(!1);for(;!(n.getLengthInBits()>=8*u||(n.put(e.PAD0,8),n.getLengthInBits()>=8*u));)n.put(e.PAD1,8);return e.createBytes(n,i)},e.createBytes=function(o,e){for(var r=0,t=0,i=0,n=new Array(e.length),a=new Array(e.length),d=0;d=0?c.get(m):0;}}var v=0;for(g=0;g=0;)e^=h.G15<=0;)e^=h.G18<>>=1;return e},getPatternPosition:function(o){return h.PATTERN_POSITION_TABLE[o-1]},getMask:function(o,e,r){switch(o){case i:return (e+r)%2==0;case n:return e%2==0;case a:return r%3==0;case d:return (e+r)%3==0;case u:return (Math.floor(e/2)+Math.floor(r/3))%2==0;case s:return e*r%2+e*r%3==0;case g:return (e*r%2+e*r%3)%2==0;case l:return (e*r%3+(e+r)%2)%2==0;default:throw new Error("bad maskPattern:"+o)}},getErrorCorrectPolynomial:function(o){for(var e=new f([1],0),r=0;r5&&(r+=3+n-5);}for(t=0;t=256;)o-=255;return c.EXP_TABLE[o]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},m=0;m<8;m++)c.EXP_TABLE[m]=1<t,set(o){t=Number(o);}},typeNumber:{get:()=>i,set(o){i=Number(o);}},margin:{get:()=>n,set(o){n=Number(o);}},backgroundImageWidth:{get(){return void 0===a?this.dynamicSize:this.useDynamicSize?this.dynamicSize/this.size*a:a},set(o){a=Number(o);}},backgroundImageHeight:{get(){return void 0===d?this.dynamicSize:this.useDynamicSize?this.dynamicSize/this.size*d:d},set(o){d=Number(o);}},backgroundImageX:{get(){return void 0===u?0:this.useDynamicSize?this.dynamicSize/this.size*u:u},set(o){u=Number(o);}},backgroundImageY:{get(){return void 0===s?0:this.useDynamicSize?this.dynamicSize/this.size*s:s},set(o){s=Number(o);}},backgroundPadding:{get:()=>g,set(o){g=o>1?1:o<0?0:o;}},foregroundImageWidth:{get(){return void 0===l?(this.dynamicSize-2*this.margin)/4:this.useDynamicSize?this.dynamicSize/this.size*l:l},set(o){l=Number(o);}},foregroundImageHeight:{get(){return void 0===h?(this.dynamicSize-2*this.margin)/4:this.useDynamicSize?this.dynamicSize/this.size*h:h},set(o){h=Number(o);}},foregroundImageX:{get(){return void 0===c?this.dynamicSize/2-this.foregroundImageWidth/2:this.useDynamicSize?this.dynamicSize/this.size*c:c},set(o){c=Number(o);}},foregroundImageY:{get(){return void 0===m?this.dynamicSize/2-this.foregroundImageHeight/2:this.useDynamicSize?this.dynamicSize/this.size*m:m},set(o){m=Number(o);}},foregroundImagePadding:{get(){return this.useDynamicSize?this.dynamicSize/this.size*f:f},set(o){f=Number(o);}},foregroundImageBorderRadius:{get(){return this.useDynamicSize?this.dynamicSize/this.size*v:v},set(o){v=Number(o);}},foregroundImageShadowOffsetX:{get(){return this.useDynamicSize?this.dynamicSize/this.size*p:p},set(o){p=Number(o);}},foregroundImageShadowOffsetY:{get(){return this.useDynamicSize?this.dynamicSize/this.size*y:y},set(o){y=Number(o);}},foregroundImageShadowBlur:{get(){return this.useDynamicSize?this.dynamicSize/this.size*k:k},set(o){k=Number(o);}},foregroundPadding:{get:()=>w,set(o){w=o>1?1:o<0?0:o;}},positionProbeBackgroundColor:{get(){return I||this.backgroundColor},set(o){I=o;}},positionProbeForegroundColor:{get(){return B||this.foregroundColor},set(o){B=o;}},separatorColor:{get(){return S||this.backgroundColor},set(o){S=o;}},positionAdjustBackgroundColor:{get(){return P||this.backgroundColor},set(o){P=o;}},positionAdjustForegroundColor:{get(){return E||this.foregroundColor},set(o){E=o;}},timingBackgroundColor:{get(){return L||this.backgroundColor},set(o){L=o;}},timingForegroundColor:{get(){return D||this.foregroundColor},set(o){D=o;}},typeNumberBackgroundColor:{get(){return T||this.backgroundColor},set(o){T=o;}},typeNumberForegroundColor:{get(){return A||this.foregroundColor},set(o){A=o;}},darkBlockColor:{get(){return N||this.foregroundColor},set(o){N=o;}},canvasContext:{get(){if(void 0===M)throw console.error("[uQRCode]: use drawCanvas, you need to set the canvasContext!"),new b.Error("use drawCanvas, you need to set the canvasContext!");return M},set(o){M=C(o);}}}),b.plugins.forEach((o=>o(b,this,!1))),o&&this.setOptions(o),e&&(this.canvasContext=C(e));}return f.prototype={get:function(o){return this.num[o]},getLength:function(){return this.num.length},multiply:function(o){for(var e=new Array(this.getLength()+o.getLength()-1),r=0;r>>7-o%8&1)},put:function(o,e){for(var r=0;r>>e-r-1&1));},getLengthInBits:function(){return this.length},putBit:function(o){var e=Math.floor(this.length/8);this.buffer.length<=e&&this.buffer.push(0),o&&(this.buffer[e]|=128>>>this.length%8),this.length++;}},e.errorCorrectLevel=t,b.errorCorrectLevel=e.errorCorrectLevel,b.Error=function(o){this.errMsg="[uQRCode]: "+o;},b.plugins=[],b.use=function(o){"function"==typeof o&&b.plugins.push(o);},b.prototype.loadImage=function(o){return Promise.resolve(o)},b.prototype.setOptions=function(o){var e,r,t,i,n,a,d,u,s,g,l,h,c,m,f,v,p,C,b,y,k,w,I,B,S,P,E,L,D,T,A,N,M,z,_,O,R,x,F,H,X,Y,j,W,G,K,Q,U,$,J,q,V,Z,oo,eo,ro;o&&(Object.keys(o).forEach((e=>{this[e]=o[e];})),function(o={},e={},r=!1){let t;for(var i in t=r?o:{...o},e){var n=e[i];null!=n&&(n.constructor==Object?t[i]=this.deepReplace(t[i],n):n.constructor!=String||n?t[i]=n:t[i]=t[i]);}}(this,{data:o.data||o.text,dataEncode:o.dataEncode,size:o.size,useDynamicSize:o.useDynamicSize,typeNumber:o.typeNumber,errorCorrectLevel:o.errorCorrectLevel,margin:o.margin,areaColor:o.areaColor,backgroundColor:o.backgroundColor||(null===(e=o.background)||void 0===e?void 0:e.color),backgroundImageSrc:o.backgroundImageSrc||(null===(r=o.background)||void 0===r||null===(t=r.image)||void 0===t?void 0:t.src),backgroundImageWidth:o.backgroundImageWidth||(null===(i=o.background)||void 0===i||null===(n=i.image)||void 0===n?void 0:n.width),backgroundImageHeight:o.backgroundImageHeight||(null===(a=o.background)||void 0===a||null===(d=a.image)||void 0===d?void 0:d.height),backgroundImageX:o.backgroundImageX||(null===(u=o.background)||void 0===u||null===(s=u.image)||void 0===s?void 0:s.x),backgroundImageY:o.backgroundImageY||(null===(g=o.background)||void 0===g||null===(l=g.image)||void 0===l?void 0:l.y),backgroundImageAlpha:o.backgroundImageAlpha||(null===(h=o.background)||void 0===h||null===(c=h.image)||void 0===c?void 0:c.alpha),backgroundImageBorderRadius:o.backgroundImageBorderRadius||(null===(m=o.background)||void 0===m||null===(f=m.image)||void 0===f?void 0:f.borderRadius),backgroundPadding:o.backgroundPadding,foregroundColor:o.foregroundColor||(null===(v=o.foreground)||void 0===v?void 0:v.color),foregroundImageSrc:o.foregroundImageSrc||(null===(p=o.foreground)||void 0===p||null===(C=p.image)||void 0===C?void 0:C.src),foregroundImageWidth:o.foregroundImageWidth||(null===(b=o.foreground)||void 0===b||null===(y=b.image)||void 0===y?void 0:y.width),foregroundImageHeight:o.foregroundImageHeight||(null===(k=o.foreground)||void 0===k||null===(w=k.image)||void 0===w?void 0:w.height),foregroundImageX:o.foregroundImageX||(null===(I=o.foreground)||void 0===I||null===(B=I.image)||void 0===B?void 0:B.x),foregroundImageY:o.foregroundImageY||(null===(S=o.foreground)||void 0===S||null===(P=S.image)||void 0===P?void 0:P.y),foregroundImagePadding:o.foregroundImagePadding||(null===(E=o.foreground)||void 0===E||null===(L=E.image)||void 0===L?void 0:L.padding),foregroundImageBackgroundColor:o.foregroundImageBackgroundColor||(null===(D=o.foreground)||void 0===D||null===(T=D.image)||void 0===T?void 0:T.backgroundColor),foregroundImageBorderRadius:o.foregroundImageBorderRadius||(null===(A=o.foreground)||void 0===A||null===(N=A.image)||void 0===N?void 0:N.borderRadius),foregroundImageShadowOffsetX:o.foregroundImageShadowOffsetX||(null===(M=o.foreground)||void 0===M||null===(z=M.image)||void 0===z?void 0:z.shadowOffsetX),foregroundImageShadowOffsetY:o.foregroundImageShadowOffsetY||(null===(_=o.foreground)||void 0===_||null===(O=_.image)||void 0===O?void 0:O.shadowOffsetY),foregroundImageShadowBlur:o.foregroundImageShadowBlur||(null===(R=o.foreground)||void 0===R||null===(x=R.image)||void 0===x?void 0:x.shadowBlur),foregroundImageShadowColor:o.foregroundImageShadowColor||(null===(F=o.foreground)||void 0===F||null===(H=F.image)||void 0===H?void 0:H.shadowColor),foregroundPadding:o.foregroundPadding,positionProbeBackgroundColor:o.positionProbeBackgroundColor||(null===(X=o.positionProbe)||void 0===X?void 0:X.backgroundColor)||(null===(Y=o.positionDetection)||void 0===Y?void 0:Y.backgroundColor),positionProbeForegroundColor:o.positionProbeForegroundColor||(null===(j=o.positionProbe)||void 0===j?void 0:j.foregroundColor)||(null===(W=o.positionDetection)||void 0===W?void 0:W.foregroundColor),separatorColor:o.separatorColor||(null===(G=o.separator)||void 0===G?void 0:G.color),positionAdjustBackgroundColor:o.positionAdjustBackgroundColor||(null===(K=o.positionAdjust)||void 0===K?void 0:K.backgroundColor)||(null===(Q=o.alignment)||void 0===Q?void 0:Q.backgroundColor),positionAdjustForegroundColor:o.positionAdjustForegroundColor||(null===(U=o.positionAdjust)||void 0===U?void 0:U.foregroundColor)||(null===($=o.alignment)||void 0===$?void 0:$.foregroundColor),timingBackgroundColor:o.timingBackgroundColor||(null===(J=o.timing)||void 0===J?void 0:J.backgroundColor),timingForegroundColor:o.timingForegroundColor||(null===(q=o.timing)||void 0===q?void 0:q.foregroundColor),typeNumberBackgroundColor:o.typeNumberBackgroundColor||(null===(V=o.typeNumber)||void 0===V?void 0:V.backgroundColor)||(null===(Z=o.versionInformation)||void 0===Z?void 0:Z.backgroundColor),typeNumberForegroundColor:o.typeNumberForegroundColor||(null===(oo=o.typeNumber)||void 0===oo?void 0:oo.foregroundColor)||(null===(eo=o.versionInformation)||void 0===eo?void 0:eo.foregroundColor),darkBlockColor:o.darkBlockColor||(null===(ro=o.darkBlock)||void 0===ro?void 0:ro.color)},!0));},b.prototype.make=function(){let{foregroundColor:o,backgroundColor:r,typeNumber:t,errorCorrectLevel:i,data:n,dataEncode:a,size:d,margin:u,useDynamicSize:s}=this;if(o===r)throw console.error("[uQRCode]: foregroundColor and backgroundColor cannot be the same!"),new b.Error("foregroundColor and backgroundColor cannot be the same!");a&&(n=function(o){o=o.toString();for(var e,r="",t=0;t=1&&e<=127?r+=o.charAt(t):e>2047?(r+=String.fromCharCode(224|e>>12&15),r+=String.fromCharCode(128|e>>6&63),r+=String.fromCharCode(128|e>>0&63)):(r+=String.fromCharCode(192|e>>6&31),r+=String.fromCharCode(128|e>>0&63));return r}(n));var g=new e(t,i);g.addData(n),g.make(),this.base=g,this.typeNumber=g.typeNumber,this.modules=g.modules,this.moduleCount=g.moduleCount,this.dynamicSize=s?Math.ceil((d-2*u)/g.moduleCount)*g.moduleCount+2*u:d,function(o){let{dynamicSize:e,margin:r,backgroundColor:t,backgroundPadding:i,foregroundColor:n,foregroundPadding:a,modules:d,moduleCount:u}=o;var s=(e-2*r)/u,g=s,l=0;i>0&&(g-=2*(l=g*i/2));var h=s,c=0;a>0&&(h-=2*(c=h*a/2));for(var m=0;m{var r=e[o[0]][o[1]],a=e[o[0]+n][o[1]],d=e[o[0]][o[1]+n];d.type.push("positionProbe"),a.type.push("positionProbe"),r.type.push("positionProbe"),r.color=1==o[2]?i:t,a.color=1==o[2]?i:t,d.color=1==o[2]?i:t;}));}(this),function(o){let{modules:e,moduleCount:r,separatorColor:t}=o;[[7,0],[7,1],[7,2],[7,3],[7,4],[7,5],[7,6],[7,7],[0,7],[1,7],[2,7],[3,7],[4,7],[5,7],[6,7]].forEach((o=>{var i=e[o[0]][o[1]],n=e[r-o[0]-1][o[1]],a=e[o[0]][r-o[1]-1];a.type.push("separator"),n.type.push("separator"),i.type.push("separator"),i.color=t,n.color=t,a.color=t;}));}(this),function(o){let{typeNumber:e,modules:r,moduleCount:t,foregroundColor:i,backgroundColor:n,positionAdjustForegroundColor:a,positionAdjustBackgroundColor:d,timingForegroundColor:u,timingBackgroundColor:s}=o;var g=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]][e-1];if(g)for(var l=[[-2,-2,1],[-1,-2,1],[0,-2,1],[1,-2,1],[2,-2,1],[-2,-1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[2,-1,1],[-2,0,1],[-1,0,0],[0,0,1],[1,0,0],[2,0,1],[-2,1,1],[-1,1,0],[0,1,0],[1,1,0],[2,1,1],[-2,2,1],[-1,2,1],[0,2,1],[1,2,1],[2,2,1]],h=g.length,c=0;ct-9-1&&v<9||v>t-9-1&&f<9||l.forEach((o=>{var e=r[f+o[0]][v+o[1]];e.type.push("positionAdjust"),e.type.includes("timing")?1==o[2]?e.color=a==i?u:a:e.color=a==i&&d==n?s:d:e.color=1==o[2]?a:d;}));}}(this),function(o){let{modules:e,moduleCount:r,timingForegroundColor:t,timingBackgroundColor:i}=o;for(var n=r-16,a=0;a{var t=r[o[0]][o[1]];t.type.push("typeNumber"),t.color="1"==d[e]?n:i;}));}(this),this.isMaked=!0,this.drawModules=[];},b.prototype.getDrawModules=function(){if(this.drawModules&&this.drawModules.length>0)return this.drawModules;let o=this.drawModules=[],{modules:e,moduleCount:r,dynamicSize:t,areaColor:i,backgroundImageSrc:n,backgroundImageX:a,backgroundImageY:d,backgroundImageWidth:u,backgroundImageHeight:s,backgroundImageAlpha:g,backgroundImageBorderRadius:l,foregroundImageSrc:h,foregroundImageX:c,foregroundImageY:m,foregroundImageWidth:f,foregroundImageHeight:v,foregroundImagePadding:p,foregroundImageBackgroundColor:C,foregroundImageBorderRadius:b,foregroundImageShadowOffsetX:y,foregroundImageShadowOffsetY:k,foregroundImageShadowBlur:w,foregroundImageShadowColor:I}=this;i&&o.push({name:"area",type:"area",color:i,x:0,y:0,width:t,height:t}),n&&o.push({name:"backgroundImage",type:"image",imageSrc:n,mappingName:"backgroundImageSrc",x:a,y:d,width:u,height:s,alpha:g,borderRadius:l});for(var B=0;Bo||0>e||o>=r||e>=r)&&this.modules[o][e].isBlack},b.prototype.drawCanvas=function(o){let{isMaked:e,canvasContext:r,useDynamicSize:t,dynamicSize:i,foregroundColor:n,foregroundPadding:a,backgroundColor:d,backgroundPadding:u,drawReserve:s,margin:g}=this;if(!e)return console.error("[uQRCode]: please execute the make method first!"),Promise.reject(new b.Error("please execute the make method first!"));let l=this.getDrawModules(),h=async(e,t)=>{try{r.draw(o);for(var i=0;i0&&(r.beginPath(),r.moveTo(a+c,d),r.arcTo(a+u,d,a+u,d+g,c),r.arcTo(a+u,d+g,a,d+g,c),r.arcTo(a,d+g,a,d,c),r.arcTo(a,d,a+u,d,c),r.closePath(),r.setStrokeStyle("rgba(0,0,0,0)"),r.stroke(),r.clip());try{var h=await this.loadImage(n.imageSrc);r.drawImage(h,a,d,u,g);}catch(o){throw console.error(`[uQRCode]: ${n.mappingName} invalid!`),new b.Error(`${n.mappingName} invalid!`)}}else if("foregroundImage"===n.name){a=Math.round(n.x),d=Math.round(n.y),u=Math.round(n.width),g=Math.round(n.height);var c,m=Math.round(n.padding);u<2*(c=Math.round(n.borderRadius))&&(c=u/2),g<2*c&&(c=g/2);var f=a-m,v=d-m,p=u+2*m,C=g+2*m,y=Math.round(p/u*c);p<2*y&&(y=p/2),C<2*y&&(y=C/2),r.save(),r.setShadow(n.shadowOffsetX,n.shadowOffsetY,n.shadowBlur,n.shadowColor),y>0?(r.beginPath(),r.moveTo(f+y,v),r.arcTo(f+p,v,f+p,v+C,y),r.arcTo(f+p,v+C,f,v+C,y),r.arcTo(f,v+C,f,v,y),r.arcTo(f,v,f+p,v,y),r.closePath(),r.setFillStyle(n.backgroundColor),r.fill()):(r.setFillStyle(n.backgroundColor),r.fillRect(f,v,p,C)),r.restore(),r.save(),y>0?(r.beginPath(),r.moveTo(f+y,v),r.arcTo(f+p,v,f+p,v+C,y),r.arcTo(f+p,v+C,f,v+C,y),r.arcTo(f,v+C,f,v,y),r.arcTo(f,v,f+p,v,y),r.closePath(),r.setFillStyle(m>0?n.backgroundColor:"rgba(0,0,0,0)"),r.fill()):(r.setFillStyle(m>0?n.backgroundColor:"rgba(0,0,0,0)"),r.fillRect(f,v,p,C)),r.restore(),c>0&&(r.beginPath(),r.moveTo(a+c,d),r.arcTo(a+u,d,a+u,d+g,c),r.arcTo(a+u,d+g,a,d+g,c),r.arcTo(a,d+g,a,d,c),r.arcTo(a,d,a+u,d,c),r.closePath(),r.setStrokeStyle("rgba(0,0,0,0)"),r.stroke(),r.clip());try{h=await this.loadImage(n.imageSrc);r.drawImage(h,a,d,u,g);}catch(o){throw console.error(`[uQRCode]: ${n.mappingName} invalid!`),new b.Error(`${n.mappingName} invalid!`)}}}s&&r.draw(!0),r.restore();}r.draw(!0),setTimeout(e,150);}catch(o){t(o);}};return new Promise(((o,e)=>{h(o,e);}))},b.prototype.draw=function(o){return this.drawCanvas(o)},b.prototype.register=function(o){o&&o(b,this,!0);},b}));
\ No newline at end of file
diff --git a/mp-sc-frontend/src/main.ts b/mp-sc-frontend/src/main.ts
new file mode 100644
index 0000000..8ec1058
--- /dev/null
+++ b/mp-sc-frontend/src/main.ts
@@ -0,0 +1,19 @@
+import { createSSRApp } from 'vue'
+import App from './App.vue'
+import { requestInterceptor } from './http/interceptor'
+import { routeInterceptor } from './router/interceptor'
+
+import store from './store'
+import '@/style/index.scss'
+import 'virtual:uno.css'
+
+export function createApp() {
+ const app = createSSRApp(App)
+ app.use(store)
+ app.use(routeInterceptor)
+ app.use(requestInterceptor)
+
+ return {
+ app,
+ }
+}
diff --git a/mp-sc-frontend/src/pages/auth/README.md b/mp-sc-frontend/src/pages/auth/README.md
new file mode 100644
index 0000000..c851a76
--- /dev/null
+++ b/mp-sc-frontend/src/pages/auth/README.md
@@ -0,0 +1,20 @@
+# 登录页
+需要输入账号、密码/验证码的登录页。
+
+## 适用性
+
+本页面主要用于 `h5` 和 `APP`。
+
+小程序通常有平台的登录方式 `uni.login` 通常用不到登录页,所以不适用于 `小程序`。(即默认情况下,小程序环境是不会走登录拦截逻辑的。)
+
+但是如果您的小程序也需要现实的 `登录页` 那也是可以使用的。
+
+在 `src/router/config.ts` 中有一个变量 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在小程序中使用 `H5的登录页`。
+
+更多信息请看 `src/router` 文件夹的内容。
+
+## 登录跳转
+
+目前登录的跳转逻辑主要在 `src/router/interceptor.ts` 和 `src/pages/login/login.vue` 里面,默认会在登录后自动重定向到来源/配置的页面。
+
+如果与您的业务不符,您可以自行修改。
diff --git a/mp-sc-frontend/src/pages/auth/login.vue b/mp-sc-frontend/src/pages/auth/login.vue
new file mode 100644
index 0000000..aad1360
--- /dev/null
+++ b/mp-sc-frontend/src/pages/auth/login.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+ 四川凌空天行科技有限公司
+
+
+
+
+ 微信一键登录
+
+
+
+
+ 暂不登录
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/pages/auth/register.vue b/mp-sc-frontend/src/pages/auth/register.vue
new file mode 100644
index 0000000..e9809f0
--- /dev/null
+++ b/mp-sc-frontend/src/pages/auth/register.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+ 注册页
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/pages/index/index.vue b/mp-sc-frontend/src/pages/index/index.vue
new file mode 100644
index 0000000..a896de1
--- /dev/null
+++ b/mp-sc-frontend/src/pages/index/index.vue
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+
+
+ 欢迎使用
+ 四川凌空访客预约系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.content }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentNotice.title }}
+
+
+ {{ currentNotice.content }}
+
+
+ 关闭
+
+
+
+
+
+
+
+
+
+
+ 功能模块
+
+
+
+
+
+
+
+ {{ mod.title }}
+ {{ mod.desc }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mp-sc-frontend/src/pages/me/me.vue b/mp-sc-frontend/src/pages/me/me.vue
new file mode 100644
index 0000000..f7f7b3e
--- /dev/null
+++ b/mp-sc-frontend/src/pages/me/me.vue
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+
+
+ 立即登录
+ 登录后享受更多服务
+
+
+
+
+
+
+
+
+
+ {{ userInfo.nickname || userInfo.real_name || '未设置昵称' }}
+
+
+ {{ userInfo.role_name || '访客' }}
+
+ {{ userInfo.department_name }}
+ {{ userInfo.position }}
+
+
+
+
+
+
+
+
+ 账号ID
+ {{ userInfo.id || '' }}
+
+
+ 昵称
+
+ {{ userInfo.nickname || '未设置' }}
+
+
+
+
+ 姓名
+
+ {{ userInfo.real_name || '未设置' }}
+
+
+
+
+ 手机号
+
+ {{ userInfo.phone || '未绑定' }}
+
+
+
+
+
+
+
+
+
+
+ 📜
+
+ 申请角色认证
+
+
+ {{ certStatusText }}
+
+
+
+
+
+
+
+
+
+ 常用功能
+
+
+
+
+ ⚙️
+
+ 权限管理
+
+
+
+
+ 📄
+
+ 隐私政策
+
+
+
+
+
+
+
+
+ 设置昵称
+
+
+
+
+ 取消
+
+
+
+
+
+ 修改姓名
+
+
+ 取消
+ 确定
+
+
+
+
+
+ 绑定手机号
+ 绑定手机号
+
+
+ 暂不绑定
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/pages/public/invitation.vue b/mp-sc-frontend/src/pages/public/invitation.vue
new file mode 100644
index 0000000..c75acb0
--- /dev/null
+++ b/mp-sc-frontend/src/pages/public/invitation.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+ 访客邀请
+
+ {{ invitation.employee?.name }} 邀请您来访
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 使用邀请创建预约
+
+
+
+
+ 邀请已失效
+
+
+
+
+
+
+ 邀请不存在或已过期
+
+
+
+
+
diff --git a/mp-sc-frontend/src/pages/public/webview.vue b/mp-sc-frontend/src/pages/public/webview.vue
new file mode 100644
index 0000000..254c412
--- /dev/null
+++ b/mp-sc-frontend/src/pages/public/webview.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ 链接无效
+
+
+
+
+
diff --git a/mp-sc-frontend/src/router/README.md b/mp-sc-frontend/src/router/README.md
new file mode 100644
index 0000000..60e3084
--- /dev/null
+++ b/mp-sc-frontend/src/router/README.md
@@ -0,0 +1,55 @@
+# 登录 说明
+
+## 登录 2种策略
+- 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
+- 默认需要登录策略: DEFAULT_NEED_LOGIN
+
+### 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
+进入任何页面都不需要登录,只有进入到黑名单中的页面/或者页面中某些动作需要登录,才需要登录。
+
+比如大部分2C的应用,美团、今日头条、抖音等,都可以直接浏览,只有点赞、评论、分享等操作或者去特殊页面(比如个人中心),才需要登录。
+
+### 默认需要登录策略: DEFAULT_NEED_LOGIN
+
+进入任何页面都需要登录,只有进入到白名单中的页面,才不需要登录。默认进入应用需要先去登录页。
+
+比如大部分2B和后台管理类的应用,比如企业微信、钉钉、飞书、内部报表系统、CMS系统等,都需要登录,只有登录后,才能使用。
+
+### EXCLUDE_LOGIN_PATH_LIST
+`EXCLUDE_LOGIN_PATH_LIST` 表示排除的路由列表。
+
+在 `默认无需登录策略: DEFAULT_NO_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才需要登录,相当于黑名单。
+
+在 `默认需要登录策略: DEFAULT_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才不需要登录,相当于白名单。
+
+### excludeLoginPath
+definePage 中可以通过 `excludeLoginPath` 来配置路由是否需要登录。(类似过去的 needLogin 的功能)
+
+```ts
+definePage({
+ style: {
+ navigationBarTitleText: '关于',
+ },
+ // 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹
+ excludeLoginPath: true,
+ // 角色授权(可选):如果需要根据角色授权,就配置这个
+ roleAuth: {
+ field: 'role',
+ value: 'admin',
+ redirect: '/pages/auth/403',
+ },
+})
+```
+
+## 登录注册页路由
+
+登录页 `login.vue` 对应路由是 `/pages/login/login`.
+注册页 `register.vue` 对应路由是 `/pages/login/register`.
+
+## 登录注册页适用性
+
+登录注册页主要适用于 `h5` 和 `App`,默认不适用于 `小程序`,因为 `小程序` 通常会使用平台提供的快捷登录。
+
+特殊情况例外,如业务需要跨平台复用登录注册页时,也可以用在 `小程序` 上,所以主要还是看业务需求。
+
+通过一个参数 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在 `小程序` 中使用 `H5登录页` 的登录逻辑。
diff --git a/mp-sc-frontend/src/router/config.ts b/mp-sc-frontend/src/router/config.ts
new file mode 100644
index 0000000..493dafc
--- /dev/null
+++ b/mp-sc-frontend/src/router/config.ts
@@ -0,0 +1,28 @@
+import { getAllPages } from '@/utils'
+
+export const LOGIN_STRATEGY_MAP = {
+ DEFAULT_NO_NEED_LOGIN: 0, // 黑名单策略,默认可以进入APP
+ DEFAULT_NEED_LOGIN: 1, // 白名单策略,默认不可以进入APP,需要强制登录
+}
+// 使用白名单策略:默认需要登录才能访问
+export const LOGIN_STRATEGY = LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN
+export const isNeedLoginMode = LOGIN_STRATEGY === LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN
+
+export const LOGIN_PAGE = '/pages/auth/login'
+export const REGISTER_PAGE = '/pages/auth/register'
+
+export const LOGIN_PAGE_LIST = [LOGIN_PAGE, REGISTER_PAGE]
+
+// 在 definePage 里面配置了 excludeLoginPath 的页面,功能与 EXCLUDE_LOGIN_PATH_LIST 相同
+export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path)
+
+// 白名单:这些页面不需要登录即可访问
+export const EXCLUDE_LOGIN_PATH_LIST = [
+ '/pages/auth/login',
+ '/pages/auth/register',
+ '/pages/public/invitation',
+ ...excludeLoginPathList,
+]
+
+// 在小程序里面是否使用H5的登录页,默认为 false
+export const LOGIN_PAGE_ENABLE_IN_MP = false
\ No newline at end of file
diff --git a/mp-sc-frontend/src/router/interceptor.ts b/mp-sc-frontend/src/router/interceptor.ts
new file mode 100644
index 0000000..9c7a9ac
--- /dev/null
+++ b/mp-sc-frontend/src/router/interceptor.ts
@@ -0,0 +1,145 @@
+import { isMp } from '@uni-helper/uni-env'
+/**
+ * by 菲鸽 on 2025-08-19
+ * 路由拦截,通常也是登录拦截
+ * 黑、白名单的配置,请看 config.ts 文件, EXCLUDE_LOGIN_PATH_LIST
+ */
+import { useTokenStore } from '@/store/token'
+import { isPageTabbar, tabbarStore } from '@/tabbar/store'
+import { getAllPages, getLastPage, HOME_PAGE, parseUrlToObj } from '@/utils/index'
+import { EXCLUDE_LOGIN_PATH_LIST, isNeedLoginMode, LOGIN_PAGE, LOGIN_PAGE_ENABLE_IN_MP } from './config'
+
+export const FG_LOG_ENABLE = false
+
+export function judgeIsExcludePath(path: string) {
+ const isDev = import.meta.env.DEV
+ if (!isDev) {
+ return EXCLUDE_LOGIN_PATH_LIST.includes(path)
+ }
+ const allExcludeLoginPages = getAllPages('excludeLoginPath') // dev 环境下,需要每次都重新获取,否则新配置就不会生效
+ return EXCLUDE_LOGIN_PATH_LIST.includes(path) || (isDev && allExcludeLoginPages.some(page => page.path === path))
+}
+
+export const navigateToInterceptor = {
+ // 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
+ // 增加对相对路径的处理,BY 网友 @ideal
+ invoke({ url, query }: { url: string, query?: Record }) {
+ if (url === undefined) {
+ return
+ }
+ let { path, query: _query } = parseUrlToObj(url)
+
+ FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------')
+ FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query)
+ const myQuery = { ..._query, ...query }
+ // /pages/route-interceptor/index?name=feige&age=30
+ FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query)
+ FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery)
+
+ // 处理相对路径
+ if (!path.startsWith('/')) {
+ const currentPath = getLastPage()?.route || ''
+ const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`
+ const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/'))
+ path = `${baseDir}/${path}`
+ }
+
+ // // 处理路由不存在的情况
+ // if (path !== '/' && !getAllPages().some(page => page.path === path)) {
+ // console.warn('路由不存在:', path)
+ // return false // 明确表示阻止原路由继续执行
+ // }
+
+ // // 插件页面
+ // if (url.startsWith('plugin://')) {
+ // FG_LOG_ENABLE && console.log('路由拦截器 4: plugin:// 路径 ==>', url)
+ // path = url
+ // }
+
+ // 处理直接进入路由非首页时,tabbarIndex 不正确的问题
+ tabbarStore.setAutoCurIdx(path)
+
+ // 小程序里面使用平台自带的登录,则不走下面的逻辑
+ if (isMp && !LOGIN_PAGE_ENABLE_IN_MP) {
+ return true // 明确表示允许路由继续执行
+ }
+
+ const tokenStore = useTokenStore()
+ FG_LOG_ENABLE && console.log('tokenStore.hasLogin:', tokenStore.hasLogin)
+
+ // 不管黑白名单,登录了就直接去吧(但是当前不能是登录页)
+ if (tokenStore.hasLogin) {
+ if (path !== LOGIN_PAGE) {
+ return true // 明确表示允许路由继续执行
+ }
+ else {
+ console.log('已经登录,但是还在登录页', myQuery.redirect)
+ const url = myQuery.redirect || HOME_PAGE
+ if (isPageTabbar(url)) {
+ uni.switchTab({ url })
+ }
+ else {
+ uni.navigateTo({ url })
+ }
+ return false // 明确表示阻止原路由继续执行
+ }
+ }
+ let fullPath = path
+
+ if (Object.keys(myQuery).length) {
+ fullPath += `?${Object.keys(myQuery).map(key => `${key}=${myQuery[key]}`).join('&')}`
+ }
+ const redirectUrl = `${LOGIN_PAGE}?redirect=${encodeURIComponent(fullPath)}`
+
+ // #region 1/2 默认需要登录的情况(白名单策略) ---------------------------
+ if (isNeedLoginMode) {
+ // 需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示白名单,可以直接通过
+ if (judgeIsExcludePath(path)) {
+ return true // 明确表示允许路由继续执行
+ }
+ // 否则需要重定向到登录页
+ else {
+ if (path === LOGIN_PAGE) {
+ return true // 明确表示允许路由继续执行
+ }
+ FG_LOG_ENABLE && console.log('1 isNeedLogin(白名单策略) redirectUrl:', redirectUrl)
+ uni.navigateTo({ url: redirectUrl })
+ return false // 明确表示阻止原路由继续执行
+ }
+ }
+ // #endregion 1/2 默认需要登录的情况(白名单策略) ---------------------------
+
+ // #region 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
+ else {
+ // 不需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示黑名单,需要重定向到登录页
+ if (judgeIsExcludePath(path)) {
+ FG_LOG_ENABLE && console.log('2 isNeedLogin(黑名单策略) redirectUrl:', redirectUrl)
+ uni.navigateTo({ url: redirectUrl })
+ return false // 修改为false,阻止原路由继续执行
+ }
+ return true // 明确表示允许路由继续执行
+ }
+ // #endregion 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
+ },
+}
+
+// 针对 chooseLocation 的特殊处理
+export const chooseLocationInterceptor = {
+ invoke(options: any) {
+ // 直接放行 chooseLocation 调用
+ FG_LOG_ENABLE && console.log('chooseLocation 调用,直接放行:', options)
+ return true
+ },
+}
+
+export const routeInterceptor = {
+ install() {
+ uni.addInterceptor('navigateTo', navigateToInterceptor)
+ uni.addInterceptor('reLaunch', navigateToInterceptor)
+ uni.addInterceptor('redirectTo', navigateToInterceptor)
+ uni.addInterceptor('switchTab', navigateToInterceptor)
+
+ // 添加 chooseLocation 的拦截器,确保直接放行
+ uni.addInterceptor('chooseLocation', chooseLocationInterceptor)
+ },
+}
diff --git a/mp-sc-frontend/src/router/permission.ts b/mp-sc-frontend/src/router/permission.ts
new file mode 100644
index 0000000..38fa06f
--- /dev/null
+++ b/mp-sc-frontend/src/router/permission.ts
@@ -0,0 +1,11 @@
+import { tabbarStore } from '@/tabbar/store'
+
+export const permission = {
+ install(router) {
+ router.beforeEach((to, from, next) => {
+ const path = to.path
+ tabbarStore.setAutoCurIdx(path)
+ next()
+ })
+ },
+}
diff --git a/mp-sc-frontend/src/service/index.ts b/mp-sc-frontend/src/service/index.ts
new file mode 100644
index 0000000..3bfbb5b
--- /dev/null
+++ b/mp-sc-frontend/src/service/index.ts
@@ -0,0 +1,6 @@
+/* eslint-disable */
+// @ts-ignore
+export * from './types';
+
+export * from './listAll';
+export * from './info';
diff --git a/mp-sc-frontend/src/service/info.ts b/mp-sc-frontend/src/service/info.ts
new file mode 100644
index 0000000..fe09da5
--- /dev/null
+++ b/mp-sc-frontend/src/service/info.ts
@@ -0,0 +1,14 @@
+/* eslint-disable */
+// @ts-ignore
+import request from '@/http/vue-query';
+import { CustomRequestOptions_ } from '@/http/types';
+
+import * as API from './types';
+
+/** 用户信息 GET /user/info */
+export function infoUsingGet({ options }: { options?: CustomRequestOptions_ }) {
+ return request('/user/info', {
+ method: 'GET',
+ ...(options || {}),
+ });
+}
diff --git a/mp-sc-frontend/src/service/listAll.ts b/mp-sc-frontend/src/service/listAll.ts
new file mode 100644
index 0000000..bc1c683
--- /dev/null
+++ b/mp-sc-frontend/src/service/listAll.ts
@@ -0,0 +1,18 @@
+/* eslint-disable */
+// @ts-ignore
+import request from '@/http/vue-query';
+import { CustomRequestOptions_ } from '@/http/types';
+
+import * as API from './types';
+
+/** 用户列表 GET /user/listAll */
+export function listAllUsingGet({
+ options,
+}: {
+ options?: CustomRequestOptions_;
+}) {
+ return request('/user/listAll', {
+ method: 'GET',
+ ...(options || {}),
+ });
+}
diff --git a/mp-sc-frontend/src/service/types.ts b/mp-sc-frontend/src/service/types.ts
new file mode 100644
index 0000000..4e46b61
--- /dev/null
+++ b/mp-sc-frontend/src/service/types.ts
@@ -0,0 +1,29 @@
+/* eslint-disable */
+// @ts-ignore
+
+export type InfoUsingGetResponse = {
+ code: number;
+ msg: string;
+ data: UserItem;
+};
+
+export type InfoUsingGetResponses = {
+ 200: InfoUsingGetResponse;
+};
+
+export type ListAllUsingGetResponse = {
+ code: number;
+ msg: string;
+ data: UserItem[];
+};
+
+export type ListAllUsingGetResponses = {
+ 200: ListAllUsingGetResponse;
+};
+
+export type UserItem = {
+ userId: number;
+ username: string;
+ nickname: string;
+ avatar: string;
+};
diff --git a/mp-sc-frontend/src/static/app/icons/1024x1024.png b/mp-sc-frontend/src/static/app/icons/1024x1024.png
new file mode 100644
index 0000000..08dbd5f
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/1024x1024.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/120x120.png b/mp-sc-frontend/src/static/app/icons/120x120.png
new file mode 100644
index 0000000..718ca79
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/120x120.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/144x144.png b/mp-sc-frontend/src/static/app/icons/144x144.png
new file mode 100644
index 0000000..f78346b
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/144x144.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/152x152.png b/mp-sc-frontend/src/static/app/icons/152x152.png
new file mode 100644
index 0000000..f979721
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/152x152.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/167x167.png b/mp-sc-frontend/src/static/app/icons/167x167.png
new file mode 100644
index 0000000..d0aef20
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/167x167.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/180x180.png b/mp-sc-frontend/src/static/app/icons/180x180.png
new file mode 100644
index 0000000..24bd062
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/180x180.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/192x192.png b/mp-sc-frontend/src/static/app/icons/192x192.png
new file mode 100644
index 0000000..a8ea1a2
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/192x192.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/20x20.png b/mp-sc-frontend/src/static/app/icons/20x20.png
new file mode 100644
index 0000000..0abed04
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/20x20.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/29x29.png b/mp-sc-frontend/src/static/app/icons/29x29.png
new file mode 100644
index 0000000..a20d373
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/29x29.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/40x40.png b/mp-sc-frontend/src/static/app/icons/40x40.png
new file mode 100644
index 0000000..2b41be6
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/40x40.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/58x58.png b/mp-sc-frontend/src/static/app/icons/58x58.png
new file mode 100644
index 0000000..8e18b42
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/58x58.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/60x60.png b/mp-sc-frontend/src/static/app/icons/60x60.png
new file mode 100644
index 0000000..167826b
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/60x60.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/72x72.png b/mp-sc-frontend/src/static/app/icons/72x72.png
new file mode 100644
index 0000000..ddb91e3
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/72x72.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/76x76.png b/mp-sc-frontend/src/static/app/icons/76x76.png
new file mode 100644
index 0000000..0d9d28e
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/76x76.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/80x80.png b/mp-sc-frontend/src/static/app/icons/80x80.png
new file mode 100644
index 0000000..1877042
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/80x80.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/87x87.png b/mp-sc-frontend/src/static/app/icons/87x87.png
new file mode 100644
index 0000000..251fb24
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/87x87.png differ
diff --git a/mp-sc-frontend/src/static/app/icons/96x96.png b/mp-sc-frontend/src/static/app/icons/96x96.png
new file mode 100644
index 0000000..eccf396
Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/96x96.png differ
diff --git a/mp-sc-frontend/src/static/fonts/iconfont.ttf b/mp-sc-frontend/src/static/fonts/iconfont.ttf
new file mode 100644
index 0000000..9f9ba7a
Binary files /dev/null and b/mp-sc-frontend/src/static/fonts/iconfont.ttf differ
diff --git a/mp-sc-frontend/src/static/images/avatar.jpg b/mp-sc-frontend/src/static/images/avatar.jpg
new file mode 100644
index 0000000..2010a70
Binary files /dev/null and b/mp-sc-frontend/src/static/images/avatar.jpg differ
diff --git a/mp-sc-frontend/src/static/images/default-avatar.png b/mp-sc-frontend/src/static/images/default-avatar.png
new file mode 100644
index 0000000..4eb5879
Binary files /dev/null and b/mp-sc-frontend/src/static/images/default-avatar.png differ
diff --git a/mp-sc-frontend/src/static/login/login-bg.png b/mp-sc-frontend/src/static/login/login-bg.png
new file mode 100644
index 0000000..0808076
Binary files /dev/null and b/mp-sc-frontend/src/static/login/login-bg.png differ
diff --git a/mp-sc-frontend/src/static/logo.svg b/mp-sc-frontend/src/static/logo.svg
new file mode 100644
index 0000000..eaee669
--- /dev/null
+++ b/mp-sc-frontend/src/static/logo.svg
@@ -0,0 +1,33 @@
+
+
diff --git a/mp-sc-frontend/src/static/my-icons/copyright.svg b/mp-sc-frontend/src/static/my-icons/copyright.svg
new file mode 100644
index 0000000..8e69a8c
--- /dev/null
+++ b/mp-sc-frontend/src/static/my-icons/copyright.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mp-sc-frontend/src/static/tabbar/example.png b/mp-sc-frontend/src/static/tabbar/example.png
new file mode 100644
index 0000000..fd1e942
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/example.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/exampleHL.png b/mp-sc-frontend/src/static/tabbar/exampleHL.png
new file mode 100644
index 0000000..7501011
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/exampleHL.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/home.png b/mp-sc-frontend/src/static/tabbar/home.png
new file mode 100644
index 0000000..8f82e21
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/home.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/homeHL.png b/mp-sc-frontend/src/static/tabbar/homeHL.png
new file mode 100644
index 0000000..26d3761
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/homeHL.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/organization.png b/mp-sc-frontend/src/static/tabbar/organization.png
new file mode 100644
index 0000000..8f82e21
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/organization.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/organizationHL.png b/mp-sc-frontend/src/static/tabbar/organizationHL.png
new file mode 100644
index 0000000..26d3761
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/organizationHL.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/personal.png b/mp-sc-frontend/src/static/tabbar/personal.png
new file mode 100644
index 0000000..0a569a2
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/personal.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/personalHL.png b/mp-sc-frontend/src/static/tabbar/personalHL.png
new file mode 100644
index 0000000..8c3e66e
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/personalHL.png differ
diff --git a/mp-sc-frontend/src/static/tabbar/scan.png b/mp-sc-frontend/src/static/tabbar/scan.png
new file mode 100644
index 0000000..f0f60c2
Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/scan.png differ
diff --git a/mp-sc-frontend/src/store/index.ts b/mp-sc-frontend/src/store/index.ts
new file mode 100644
index 0000000..fe2ead1
--- /dev/null
+++ b/mp-sc-frontend/src/store/index.ts
@@ -0,0 +1,20 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化
+
+const store = createPinia()
+store.use(
+ createPersistedState({
+ storage: {
+ getItem: uni.getStorageSync,
+ setItem: uni.setStorageSync,
+ },
+ }),
+)
+// 立即激活 Pinia 实例, 这样即使在 app.use(store)之前调用 store 也能正常工作 (解决APP端白屏问题)
+setActivePinia(store)
+
+export default store
+
+// 模块统一导出
+export * from './token'
+export * from './user'
diff --git a/mp-sc-frontend/src/store/token.ts b/mp-sc-frontend/src/store/token.ts
new file mode 100644
index 0000000..adf599b
--- /dev/null
+++ b/mp-sc-frontend/src/store/token.ts
@@ -0,0 +1,108 @@
+import type { ILoginRes } from '@/api/types/appointment'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import {
+ loginByCode,
+} from '@/api/appointment'
+import { getWxCode } from '@/api/login'
+import { useUserStore } from './user'
+
+export const useTokenStore = defineStore(
+ 'token',
+ () => {
+ const token = ref('')
+ const tokenExpireTime = ref(0)
+ const nowTime = ref(Date.now())
+
+ const updateNowTime = () => {
+ nowTime.value = Date.now()
+ return useTokenStore()
+ }
+
+ const setToken = (val: string, expiresIn: number = 86400) => {
+ token.value = val
+ tokenExpireTime.value = Date.now() + expiresIn * 1000
+ }
+
+ const isTokenExpired = computed(() => {
+ if (!token.value) return true
+ return nowTime.value >= tokenExpireTime.value
+ })
+
+ const hasLogin = computed(() => {
+ return !!token.value && !isTokenExpired.value
+ })
+
+ const validToken = computed(() => {
+ return hasLogin.value ? token.value : ''
+ })
+
+ /** 登录成功后处理 */
+ async function _postLogin(res: ILoginRes) {
+ setToken(res.token, 86400)
+ const userStore = useUserStore()
+ if (res.user_info) {
+ userStore.setUserInfo(res.user_info)
+ } else {
+ await userStore.fetchUserInfo()
+ }
+ }
+
+ /** 微信登录 */
+ const wxLogin = async () => {
+ try {
+ const loginRes = await getWxCode()
+ const res = await loginByCode(loginRes.code)
+ await _postLogin(res)
+ uni.showToast({ title: '登录成功', icon: 'success' })
+ return res
+ } catch (error) {
+ console.error('微信登录失败:', error)
+ uni.showToast({ title: '登录失败,请重试', icon: 'error' })
+ throw error
+ } finally {
+ updateNowTime()
+ }
+ }
+
+ /** 开发环境模拟登录 */
+ const mockLogin = async (code: string) => {
+ try {
+ const res = await loginByCode(code)
+ await _postLogin(res)
+ uni.showToast({ title: '登录成功', icon: 'success' })
+ return res
+ } catch (error) {
+ console.error('登录失败:', error)
+ uni.showToast({ title: '登录失败,请重试', icon: 'error' })
+ throw error
+ } finally {
+ updateNowTime()
+ }
+ }
+
+ /** 退出登录 */
+ const logout = async () => {
+ updateNowTime()
+ token.value = ''
+ tokenExpireTime.value = 0
+ const userStore = useUserStore()
+ userStore.clearUserInfo()
+ }
+
+ return {
+ token,
+ tokenExpireTime,
+ login: mockLogin,
+ wxLogin,
+ logout,
+ hasLogin,
+ validToken,
+ setToken,
+ updateNowTime,
+ }
+ },
+ {
+ persist: true,
+ },
+)
\ No newline at end of file
diff --git a/mp-sc-frontend/src/store/user.test.ts b/mp-sc-frontend/src/store/user.test.ts
new file mode 100644
index 0000000..a2b1f26
--- /dev/null
+++ b/mp-sc-frontend/src/store/user.test.ts
@@ -0,0 +1,71 @@
+import { getUserInfo } from '@/api/login'
+import { describe, expect, it, vi } from 'vitest'
+import { useUserStore } from './user'
+
+vi.mock('@/api/login', () => ({
+ getUserInfo: vi.fn(),
+}))
+
+describe('useUserStore', () => {
+ it('初始状态:userId 为 -1,username 为空,avatar 为默认头像', () => {
+ const store = useUserStore()
+ expect(store.userInfo.userId).toBe(-1)
+ expect(store.userInfo.username).toBe('')
+ expect(store.userInfo.nickname).toBe('')
+ expect(store.userInfo.avatar).toBe('/static/images/default-avatar.png')
+ })
+
+ it('setUserInfo:正确更新用户信息', () => {
+ const store = useUserStore()
+ store.setUserInfo({
+ userId: 1,
+ username: 'testuser',
+ nickname: 'Test',
+ avatar: 'https://example.com/avatar.png',
+ })
+ expect(store.userInfo.userId).toBe(1)
+ expect(store.userInfo.username).toBe('testuser')
+ expect(store.userInfo.avatar).toBe('https://example.com/avatar.png')
+ })
+
+ it('setUserInfo:avatar 为空字符串时使用默认头像', () => {
+ const store = useUserStore()
+ store.setUserInfo({
+ userId: 2,
+ username: 'user2',
+ nickname: 'User2',
+ avatar: '',
+ })
+ expect(store.userInfo.avatar).toBe('/static/images/default-avatar.png')
+ })
+
+ it('setUserAvatar:正确更新头像', () => {
+ const store = useUserStore()
+ store.setUserAvatar('https://example.com/new-avatar.png')
+ expect(store.userInfo.avatar).toBe('https://example.com/new-avatar.png')
+ })
+
+ it('clearUserInfo:重置为初始状态并调用 uni.removeStorageSync', () => {
+ const store = useUserStore()
+ store.setUserInfo({ userId: 1, username: 'u', nickname: 'U', avatar: 'a' })
+
+ store.clearUserInfo()
+
+ expect(store.userInfo.userId).toBe(-1)
+ expect(store.userInfo.username).toBe('')
+ expect(uni.removeStorageSync).toHaveBeenCalledWith('user')
+ })
+
+ it('fetchUserInfo:调用 API 并将结果写入 store', async () => {
+ const store = useUserStore()
+ const mockUser = { userId: 42, username: 'api_user', nickname: 'API User', avatar: 'https://x.com/a.png' }
+ vi.mocked(getUserInfo).mockResolvedValue(mockUser)
+
+ await store.fetchUserInfo()
+
+ expect(store.userInfo.userId).toBe(42)
+ expect(store.userInfo.username).toBe('api_user')
+ expect(store.userInfo.nickname).toBe('API User')
+ expect(store.userInfo.avatar).toBe('https://x.com/a.png')
+ })
+})
diff --git a/mp-sc-frontend/src/store/user.ts b/mp-sc-frontend/src/store/user.ts
new file mode 100644
index 0000000..bd9bf93
--- /dev/null
+++ b/mp-sc-frontend/src/store/user.ts
@@ -0,0 +1,93 @@
+import type { IUserInfo } from '@/api/types/appointment'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import {
+ getUserInfo as _getUserInfo,
+ updateUserInfo as _updateUserInfo,
+} from '@/api/appointment'
+
+// 初始化状态
+const userInfoState: IUserInfo = {
+ id: 0,
+ openid: '',
+ nickname: '',
+ real_name: '',
+ phone: '',
+ phone_verified: false,
+ avatar_url: '',
+ face_image_url: '',
+ status: 1,
+ created_at: '',
+ updated_at: '',
+ role: '',
+ role_name: '',
+ is_formal_employee: false,
+ department_name: '',
+ position: '',
+ certification: undefined,
+}
+
+export const useUserStore = defineStore(
+ 'user',
+ () => {
+ const userInfo = ref({ ...userInfoState })
+
+ const roleCode = computed(() => userInfo.value.role || '')
+
+ // 通用角色检查函数
+ const hasRole = (code: string): boolean => roleCode.value === code
+
+ // 动态角色 getters
+ const isEmployee = computed(() => hasRole('employee'))
+ const isGuard = computed(() => hasRole('guard'))
+ const isVisitor = computed(() => hasRole('visitor') || !roleCode.value)
+ const isLeader = computed(() => hasRole('employee'))
+
+ // 是否为正式员工(来自后端 userinfo 返回)
+ const isFormalEmployee = computed(() => !!userInfo.value.is_formal_employee)
+
+ const setUserInfo = (val: IUserInfo) => {
+ console.log('设置用户信息', val)
+ userInfo.value = { ...userInfoState, ...val }
+ }
+
+ const clearUserInfo = () => {
+ userInfo.value = { ...userInfoState }
+ }
+
+ /**
+ * 获取用户信息
+ */
+ const fetchUserInfo = async () => {
+ const res = await _getUserInfo()
+ setUserInfo(res)
+ return res
+ }
+
+ /**
+ * 更新用户信息(封装 API 调用 + store 同步)
+ */
+ const updateUserInfo = async (data: Record) => {
+ await _updateUserInfo(data)
+ await fetchUserInfo()
+ }
+
+ return {
+ userInfo,
+ roleCode,
+ hasRole,
+ isEmployee,
+ isFormalEmployee,
+ isGuard,
+ isVisitor,
+ isLeader,
+ clearUserInfo,
+ fetchUserInfo,
+ setUserInfo,
+ updateUserInfo,
+ }
+ },
+ {
+ persist: true,
+ },
+)
\ No newline at end of file
diff --git a/mp-sc-frontend/src/style/iconfont.css b/mp-sc-frontend/src/style/iconfont.css
new file mode 100644
index 0000000..35da86c
--- /dev/null
+++ b/mp-sc-frontend/src/style/iconfont.css
@@ -0,0 +1,28 @@
+@font-face {
+ font-family: 'iconfont'; /* Project id 4543091 */
+ src:
+ url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAOwAAsAAAAAB9AAAANjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDHAqDBIJqATYCJAMQCwoABCAFhGcHPRvnBsgusG3kMyE15/44PsBX09waBHv0REDt97oHAQDFrOIyPirRiULQ+TJcXV0hCYTuVFcBC915/2vX/32Q80hkZ5PZGZ9snvwruVLloidKqYN6iKC53bOtbKwVLSIi3W6zCWZbs3VbER3j9JpGX3ySYcc94IQRTK5s4epS/jSqIgvg37qlY2/jwQN7D9ADpfRCmIknQByTscVZPTBr+hnnCKg2o4bjakvXEPjuY65DJGeJNtBUhn1JxOBuB2UZmUpBOXdsFp4oxOv4GHgs3h/+wRDcicqSZJG1q9kK1z/Af9NpqxjpC2QaAdpHlCFh4spcYXs5sMWpSk5wUj31G2dLQKVKkZ/w7f/8/i/A3JVUSZK9f7xIKJeU14IFpBI/Qfkkz46GT/CuaGREfCtKJUougWeQWHvVC5Lcz2BGS+SePR99vj3yjJx7h574tp7uWcOh4yfaTjS/245TT/vkQrN+a7RLkK8+Vd+bz+FSGh+9srDQKPeJ2s29z7ah4+efdoxefRbbGwfy7ht+SuIWukzsu1b6ePP+6kN1aamb47qsPim1Ia3xdEpDcl1dckPKGYnneI23+57r2W1Mmkqs6ajrChRCs5qyQ66rTVWhgZaG7toOeHm5cxn0sSQuNDEgcUTdNTSupKI1JRZih/JssAUKezPeOJJzbNozF6zWJuuVavVU5Tgtkop/SDzHa7ytvnCTq0PhkEfi4xLLtb0PuwyOAYqmrYQApFJyoJjTnfz+ve94vvv2f/yWgxl8Jd8Di2DRDPuob59mU/+VfDCROQyR8xSnmP9fXm7liagmN39OlmbvjqG0sMsJKrU0EFXogaRSH5bNY1CmxhyUq7QC1cY1T67RwuQk5CoM2RUQNLoEUb03kDS6h2XzcyjT7iOUa/QXqq1Hn6/GUBAaGcGcWJFlGUmCoVOp8kLvABHnVczGYiOE2SVEUH5OXj/TSnTCDjHAviAWcE4RZYaGWszNiKoayGSGTASeY+PcrMjNpVMvyREMDRoxBMYRVojFMkQiMOhohubdzxtAiOapMMbERpKMnQT9SL4ceQysVdJZVa9kEbsFogIcRyEUE2kN0mL7CDVIGhBzupWMEHA5bDvipgq5hKJcKef8ivbx1kC15KgcYkghhzLxYNntxoKCReJ82jAHAAA=')
+ format('woff2'),
+ url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.woff?t=1715485842402') format('woff'),
+ url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.ttf?t=1715485842402') format('truetype');
+}
+
+.iconfont {
+ font-family: 'iconfont' !important;
+ font-size: 16px;
+ font-style: normal;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-my:before {
+ content: '\e78c';
+}
+
+.icon-package:before {
+ content: '\e9c2';
+}
+
+.icon-chat:before {
+ content: '\e600';
+}
diff --git a/mp-sc-frontend/src/style/index.scss b/mp-sc-frontend/src/style/index.scss
new file mode 100644
index 0000000..195f84c
--- /dev/null
+++ b/mp-sc-frontend/src/style/index.scss
@@ -0,0 +1,39 @@
+// 测试用的 iconfont,可生效
+// @import './iconfont.css';
+
+// Wot Design Uni 图标字体本地化(微信小程序无法加载在线字体)
+@import './wd-icon-local.scss';
+
+.test {
+ // 可以通过 @apply 多个样式封装整体样式
+ @apply mt-4 ml-4;
+
+ padding-top: 4px;
+ color: red;
+}
+
+:root,
+page {
+ // 修改按主题色
+ // --wot-color-theme: #37c2bc;
+
+ // 修改按钮背景色
+ // --wot-button-primary-bg-color: green;
+}
+
+/*
+border-t-1
+由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性
+1. 防止padding和border影响元素宽度。 (https://github.com/mozdevs/cssremedy/issues/4)
+2. 允许仅通过添加边框宽度来向元素添加边框。 (https://github.com/tailwindcss/tailwindcss/pull/116)
+3. [UnoCSS]: 允许使用css变量'--un-default-border-color'覆盖默认边框颜色
+*/
+// 这个样式有重大BUG,先去掉!!(2025-08-15)
+// :not(not),
+// ::before,
+// ::after {
+// box-sizing: border-box; /* 1 */
+// border-width: 0; /* 2 */
+// border-style: solid; /* 2 */
+// border-color: var(--un-default-border-color, #e5e7eb); /* 3 */
+// }
diff --git a/mp-sc-frontend/src/style/wd-icon-local.scss b/mp-sc-frontend/src/style/wd-icon-local.scss
new file mode 100644
index 0000000..ea6cd56
--- /dev/null
+++ b/mp-sc-frontend/src/style/wd-icon-local.scss
@@ -0,0 +1,13 @@
+/**
+ * Wot Design Uni 图标字体本地化覆盖
+ * 微信小程序无法加载在线字体,使用本地字体文件替代
+ */
+
+/* #ifdef MP-WEIXIN */
+@font-face {
+ font-family: 'wd-icons';
+ src: url('/static/fonts/iconfont.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+/* #endif */
\ No newline at end of file
diff --git a/mp-sc-frontend/src/subpackages/appointment/all-appointments.vue b/mp-sc-frontend/src/subpackages/appointment/all-appointments.vue
new file mode 100644
index 0000000..70c32b0
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/all-appointments.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+ 全部
+ 待审批
+
+
+
+
+
+
+
+
+
+
+ 暂无预约
+
+
+
+
+
+
+ {{ item.status === 0 ? '待审核' : item.status === 1 ? '已通过' : item.status === 2 ? '已拒绝' : '已取消' }}
+
+ {{ item.visit_purpose || '-' }}
+
+
+
+ 公司接待人员:{{ parseEmployeeInfo(item).name || '-' }}
+ 申请人:{{ parseCreatorInfo(item).name || '-' }} |{{ item.created_at?.slice(0, 16) || '' }}
+ 来访时间:{{ item.visit_start_time?.slice(0, 16) || '-' }} ~ {{ item.visit_end_time?.slice(5, 16) || '-' }}
+
+
+
+ {{ allLoading ? '加载中...' : '加载更多' }}
+
+
+ 没有更多了
+
+
+
+
+
+
+
+
+
+
+ 暂无待审批预约
+
+
+
+
+
+ 待审批
+ {{ item.visit_purpose || '-' }}
+
+
+
+ 公司接待人员:{{ parseEmployeeInfo(item).name || '-' }}
+ 申请人:{{ parseCreatorInfo(item).name || '-' }} |{{ item.created_at?.slice(0, 16) || '' }}
+ 来访时间:{{ item.visit_start_time?.slice(0, 16) || '-' }} ~ {{ item.visit_end_time?.slice(5, 16) || '-' }}
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/appointment-detail.vue b/mp-sc-frontend/src/subpackages/appointment/appointment-detail.vue
new file mode 100644
index 0000000..e16476c
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/appointment-detail.vue
@@ -0,0 +1,725 @@
+
+
+
+
+
+
+
+
+
+
+ 预约详情
+
+
+ {{ APPOINTMENT_STATUS_MAP[detail.status]?.text || '未知' }}
+
+
+
+
+ 预约编号:{{ detail.id }} | {{ dayjs(detail.created_at).format('YYYY-MM-DD HH:mm') }}
+
+
+
+
+
+ 入场二维码
+
+
+
+ 二维码生成失败,请稍后重试
+ 二维码每5秒自动刷新
+
+
+
+
+ 出入场登记
+
+
+ 登记进场
+
+
+ 登记出场
+
+
+
+
+ 加载中...
+ 暂无进出场记录
+
+
+
+
+ {{ rec.check_type === 1 ? '进场' : '出场' }}
+
+ {{ dayjs(rec.check_time).format('YYYY-MM-DD HH:mm:ss') }}
+
+
+
+
+
+
+ 外部来访人员
+
+
+
+
+ {{ v.name }}
+ 访客{{ idx + 1 }}
+
+
+ 单位:{{ v.company || '-' }}
+
+
+ 手机号:
+ {{ v.phone }}
+
+
+ 证件:{{ v.id_type }} {{ v.id_number || '' }}
+
+
+
+
+
+
+
+ 预约信息
+
+
+ 来访目的
+ {{ detail.visit_purpose }}
+
+
+ 开始时间
+ {{ dayjs(detail.visit_start_time).format('YYYY-MM-DD HH:mm') }}
+
+
+ 结束时间
+ {{ dayjs(detail.visit_end_time).format('YYYY-MM-DD HH:mm') }}
+
+
+ 到访区域
+
+ {{ area }}
+ 无
+
+
+
+ 携带物品
+
+ {{ g.name }} x{{ g.quantity }}
+
+
+
+ 审核意见
+ {{ detail.comment }}
+
+
+
+
+
+
+ 车辆信息
+
+
+ 车辆{{ idx + 1 }}
+ {{ [v.brand, v.plate, v.color].filter(Boolean).join(' ') }}
+
+
+
+
+
+
+ 备注
+
+ {{ detail.remark }}
+
+
+
+
+
+ 申请人
+
+
+ 姓名
+ {{ detail.creator_info?.name || '-' }}
+
+
+ 手机号
+ {{ detail.creator_info?.phone || '-' }}
+
+
+
+
+
+
+ 公司接待人员
+
+
+ 姓名
+ {{ detail.employee_info?.name || '-' }}
+
+
+ 手机号
+ {{ detail.employee_info?.phone || '-' }}
+
+
+
+
+
+
+ 审核进度
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ step.label }}
+ {{ step.tag }}
+
+
+ {{ step.name }}
+ · {{ step.time }}
+
+
+ {{ step.comment }}
+
+
+
+
+
+
+
+
+
+
+
+ 拒绝
+ 通过
+
+
+
+
+
+
+
+
+ {{ actionType === 'approve' ? '确认通过' : '确认拒绝' }}
+
+
+
+ 审批意见(选填)
+
+
+
+ 取消
+
+ {{ actionType === 'approve' ? '确认通过' : '确认拒绝' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/appointment-form.vue b/mp-sc-frontend/src/subpackages/appointment/appointment-form.vue
new file mode 100644
index 0000000..dca9819
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/appointment-form.vue
@@ -0,0 +1,545 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+ 姓名
+
+
+
+
+
+ *
+ 手机号
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+ 外部来访人员
+
+
+ 已选择{{ visitors.length }}人
+ {{ visitorSummary }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+ 来访目的
+
+ visitPurpose = visitTypes[e.detail.value]">
+
+ {{ visitPurpose || '请选择' }}
+
+
+
+
+
+
+
+ *
+ 来访开始时间
+
+
+
+ {{ startDisplay }}
+
+
+
+
+
+
+
+ *
+ 来访结束时间
+
+
+
+ {{ endDisplay }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ area.label }}
+
+
+
+
+
+
+
+
+
+
+
+ 有无车辆
+
+
+
+
+
+ 车牌{{ vehiclePlates.length > 1 ? idx + 1 : '' }}
+
+
+
+
+
+
+ 新增车牌号
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 提交预约
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/appointment-list.vue b/mp-sc-frontend/src/subpackages/appointment/appointment-list.vue
new file mode 100644
index 0000000..79ac860
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/appointment-list.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 暂无预约记录
+
+
+
+
+
+ {{ APPOINTMENT_STATUS_MAP[item.status]?.text || '未知' }}
+
+ {{ item.visit_type }}
+
+
+ 被访人:{{ JSON.parse(item.employee_info || "{}")?.name || '-' }}
+ 访客:{{ JSON.parse(item.visitor_info || "{}")?.name || '-' }} {{ JSON.parse(item.visitor_info || "{}")?.phone || '' }}
+ 时间:{{ item.visit_start_time?.slice(0, 16) }} ~ {{ item.visit_end_time?.slice(11, 16) }}
+
+ 区域:{{ JSON.parse(item.areas_info || "[]").join("、") }}
+
+
+
+
+ 取消预约
+
+
+
+
+
+
+ 加载中...
+
+
+ 没有更多了
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/approval-detail.vue b/mp-sc-frontend/src/subpackages/appointment/approval-detail.vue
new file mode 100644
index 0000000..5538851
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/approval-detail.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ detail.instance.template?.name || '审批流程' }}
+
+
+ {{ instanceStatusText(detail.instance.status) }}
+
+
+
+ 发起时间:{{ detail.instance.created_at }}
+
+
+
+
+
+ 审批进度
+
+
+
+
+
+
+
+
+
+
+ {{ record.node_name }}
+
+ {{ recordStatusText(record.status) }}
+
+
+
+ 审批人:{{ record.approver.real_name || '未知' }}
+
+
+ {{ record.comment }}
+
+
+ {{ record.approved_at }}
+
+
+
+
+
+
+
+
+
+ 拒绝
+
+
+ 通过
+
+
+
+
+
+
+
+
+ {{ actionType === 'approve' ? '审批通过' : '审批拒绝' }}
+
+
+
+ 审批意见(选填)
+
+
+
+ 取消
+
+ {{ actionType === 'approve' ? '确认通过' : '确认拒绝' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/approval-list.vue b/mp-sc-frontend/src/subpackages/appointment/approval-list.vue
new file mode 100644
index 0000000..ef29e63
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/approval-list.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 暂无预约
+
+
+
+
+
+
+
+ {{ smartStatus(item).text }}
+
+ {{ item.visit_type }}
+
+
+
+
+ 申请人:{{ (JSON.parse(item.creator_info || '{}') as any)?.name || '-' }}
+ |{{ item.visit_start_time?.slice(0, 16) }}
+
+
+ 访客:{{ (JSON.parse(item.visitor_info || '{}') as any)?.name || '-' }}
+
+
+
+
+
+
+ {{ loading ? '加载中...' : '加载更多' }}
+
+
+ 没有更多了
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/employee-select.vue b/mp-sc-frontend/src/subpackages/appointment/employee-select.vue
new file mode 100644
index 0000000..c07ecd5
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/employee-select.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+ 公司接待人员信息
+
+
+ 姓名
+
+
+
+ 手机号
+
+ 验证
+
+
+
+
+
+
+
+
+
+
+ {{ verifiedInfo.name }}
+
+ 部门:{{ verifiedInfo.department }}
+ 手机:{{ verifiedInfo.phone }}
+
+
+
+
+
+
+
+
+
+ 请输入公司接待人员的真实姓名和手机号,系统将自动验证身份
+
+
+
+
+
+
+
+ {{ verifiedInfo ? '选择该员工' : '请先验证员工信息' }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mp-sc-frontend/src/subpackages/appointment/goods-select.vue b/mp-sc-frontend/src/subpackages/appointment/goods-select.vue
new file mode 100644
index 0000000..4f6a036
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/goods-select.vue
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+ {{ templates.every(t => t._checked) ? '取消全选' : '全选' }}
+
+
+
+ 已选 {{ selectedCount }} 项
+ 确定
+
+
+
+
+
+ 加载中...
+
+ 暂无保存的物品模板
+ 添加物品
+
+
+
+
+
+
+
+ {{ item.name }}
+ x{{ item.quantity }}
+
+ 型号:{{ item.model }}
+ |{{ item.remark }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增物品模板
+
+
+
+
+
+ {{ editingItem ? '编辑物品' : '新增物品' }}
+
+ 物品名称
+
+
+
+ 数量
+
+
+
+ 型号
+
+
+
+ 备注
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+ 确定要删除该物品模板吗?
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/guard-workbench.vue b/mp-sc-frontend/src/subpackages/appointment/guard-workbench.vue
new file mode 100644
index 0000000..d41d0fd
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/guard-workbench.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+ 保安工作台
+ 扫码核验、登记进出场
+
+
+
+
+
+ {{ stats.total }}
+ 今日预约
+
+
+ {{ stats.checked_in }}
+ 已进场
+
+
+ {{ stats.checked_out }}
+ 已出场
+
+
+
+
+
+
+
+ 扫码核验
+ 扫描访客二维码进行入场/出场登记
+
+
+
+
+
+
+ 操作指引
+
+ 1. 访客出示入场二维码\n2. 点击上方「扫码核验」扫描二维码\n3. 确认信息后完成入场/出场登记
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/index.vue b/mp-sc-frontend/src/subpackages/appointment/index.vue
new file mode 100644
index 0000000..d4ddfb7
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/index.vue
@@ -0,0 +1,623 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 开启消息通知
+
+
+ 订阅通知并绑定手机号,第一时间收取
+ 预约审核结果、访客到访提醒,
+ 重要信息不错过
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ subscribeEffectiveDone ? '已订阅' : '订阅通知' }}
+
+
+
+
+
+
+
+
+ {{ hasPhone ? '已绑定' : '绑定手机' }}
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+ 订阅消息通知
+
+
+ {{ subscribeEffectiveDone ? '已开启,审核结果及时推送' : '预约审核、访客到访即时送达' }}
+
+
+
+
+
+ {{ subscribeDone ? '已订阅' : '已跳过' }}
+
+
+
+
+
+
+
+
+ 暂不开启
+
+
+ 开启订阅
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 2
+
+
+
+
+
+
+
+ 绑定手机号
+
+
+
+ {{ hasPhone ? '已绑定,可正常接收通知' : '完成身份验证,保障账号安全' }}
+
+
+
+
+ 已绑定
+
+
+
+
+
+
+
+
+
+ 请先完成「订阅消息通知」
+
+
+
+
+
+
+
+ 开启访客预约之旅 →
+
+
+
+
+
+
+
+
+
+
+ 访客预约
+
+
+
+
+
+
+
+
+
+
+
+ {{ menu.title }}
+ {{ menu.desc }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/leader-trace.vue b/mp-sc-frontend/src/subpackages/appointment/leader-trace.vue
new file mode 100644
index 0000000..0e42605
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/leader-trace.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 暂无预约记录
+
+
+
+
+
+ {{ APPOINTMENT_STATUS_MAP[item.status]?.text || '未知' }}
+
+ {{ item.visit_type }}
+
+
+ 被访人:{{ JSON.parse(item.employee_info || "{}")?.name }}({{ JSON.parse(item.employee_info || "{}")?.department }})
+ 访客:{{ JSON.parse(item.visitor_info || "{}")?.name }} {{ JSON.parse(item.visitor_info || "{}")?.phone }}
+ 时间:{{ item.visit_start_time?.slice(0, 16) }}
+
+
+
+
+ 没有更多了
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/notice.vue b/mp-sc-frontend/src/subpackages/appointment/notice.vue
new file mode 100644
index 0000000..448bcd3
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/notice.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+ 访客告知书
+
+ 访客须知
+
+
+ 一、访客须携带有效身份证件、办理相关手续后进入园区并在限定区域内活动。
+
+
+ 二、未经许可,园区内禁止拍照、录音、录像。
+
+
+ 三、禁止携带非国产品牌手机进入园区。
+
+
+ 四、严禁携带管制刀具、易燃、易爆、毒害性和放射性物品进入厂区。
+
+
+ 五、园区内车辆时速不得超过30公里,并按工作人员要求停放。
+
+
+ 六、保守国家秘密是我国公民基本义务,违反法律法规将承担相应责任。
+
+
+
+
+
+
+
+ {{ canClick ? '本人已仔细阅读并同意上述条款' : `请仔细阅读(${countdown}s)` }}
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/pending-approvals.vue b/mp-sc-frontend/src/subpackages/appointment/pending-approvals.vue
new file mode 100644
index 0000000..2e8b97f
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/pending-approvals.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+ 暂无待审批任务
+
+
+
+
+
+
+
+ 待审批
+ {{ task.node_name }}
+
+
+
+
+
+
+
+ 预约编号:#{{ task.instance.business_id }}
+
+
+ 审批流程:{{ task.instance.process_def.process_name }}
+
+
+ 被访部门:{{ parseVariables(task.instance.variables).department_name || '-' }}
+
+
+ 创建时间:{{ task.created_at?.slice(0, 16) || '' }}
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/vehicle-select.vue b/mp-sc-frontend/src/subpackages/appointment/vehicle-select.vue
new file mode 100644
index 0000000..c517448
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/vehicle-select.vue
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+ {{ templates.every(t => t._checked) ? '取消全选' : '全选' }}
+
+
+
+ 已选 {{ selectedCount }} 项
+ 确定
+
+
+
+
+
+ 加载中...
+
+ 暂无保存的车辆模板
+ 添加车辆
+
+
+
+
+
+
+
+ {{ item.license_plate || item.plate }}
+
+ {{ item.brand }}
+ |{{ item.color }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增车辆模板
+
+
+
+
+
+ {{ editingItem ? '编辑车辆' : '新增车辆' }}
+
+
+ 车牌号
+
+
+
+ 品牌
+
+
+
+ 颜色
+ formColor = VEHICLE_COLORS[e.detail.value]" class="flex-1">
+
+
+ {{ formColor || '请选择颜色' }}
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+ 确定要删除该车辆模板吗?
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/visitor-edit.vue b/mp-sc-frontend/src/subpackages/appointment/visitor-edit.vue
new file mode 100644
index 0000000..7a78aee
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/visitor-edit.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/appointment/visitor-select.vue b/mp-sc-frontend/src/subpackages/appointment/visitor-select.vue
new file mode 100644
index 0000000..a02a038
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/appointment/visitor-select.vue
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+
+ {{ templates.every(t => t._checked) ? '取消全选' : '全选' }}
+
+
+
+ 已选 {{ selectedCount }} 人
+ 确定
+
+
+
+
+
+ 加载中...
+
+ 暂无常用访客
+
+
+
+
+
+
+
+ {{ item.name }}
+ {{ item.phone }}
+
+ {{ item.company || '无单位' }}
+ |{{ item.id_number }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增访客信息
+
+
+
+
+
+ {{ editingItem ? '编辑访客信息' : '新增访客信息' }}
+
+
+ 姓名
+
+
+
+ 手机号
+
+
+
+ 来访单位
+
+
+
+ 身份证号
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/subpackages/system/certification-apply.vue b/mp-sc-frontend/src/subpackages/system/certification-apply.vue
new file mode 100644
index 0000000..7d8ae7b
--- /dev/null
+++ b/mp-sc-frontend/src/subpackages/system/certification-apply.vue
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
+ ⚠️
+
+ 认证未通过
+ 审核意见:{{ myCert.comment || '无' }}
+ 申请类型:{{ roleOptions.find(o => o.value === myCert.role)?.label || myCert.role }}
+ 请修改后重新提交申请
+
+
+
+
+
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+ ✅
+ 认证成功
+ 部门:{{ empDept }}
+ 职位:{{ empPos }}
+ 返回
+
+
+ 员工认证
+ 输入姓名和手机号,系统自动匹配认证
+
+ 姓名
+
+
+
+ 手机号
+
+
+ 提交认证
+
+
+
+
+
+
+
+ 保安认证申请
+ 姓名:{{ myCert.real_name }}
+ 手机号:{{ myCert.phone }}
+ 状态:
+
+ {{ myCert.status === 0 ? '待审核' : '已通过' }}
+
+
+
+
+
+ 撤回申请
+ 返回
+
+
+
+
+
+ 保安认证
+ 提交证明材料,等待管理员审核
+
+ 姓名
+
+
+
+ 手机号
+
+
+
+ 证明材料
+
+
+
+ ×
+
+ +
+
+
+ 提交申请
+
+
+
+
diff --git a/mp-sc-frontend/src/tabbar/README.md b/mp-sc-frontend/src/tabbar/README.md
new file mode 100644
index 0000000..575d87f
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/README.md
@@ -0,0 +1,78 @@
+# tabbar 说明
+
+## tabbar 3种策略
+
+`tabbar` 分为 `4 种` 情况:
+
+- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。
+
+- 1 `原生 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。
+ - 优势:原生自带的 `tabbar`,最先渲染,有缓存。
+ - 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
+
+- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
+ - 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
+ - 劣势:首次点击 `tabbar` 会闪烁。
+
+## tabbar 配置说明
+
+- 如果使用的是 `原生tabbar`,需要配置 `nativeTabbarList`,每个 `item` 需要配置 `path`、`text`、`iconPath`、`selectedIconPath` 等属性。
+- 如果使用的是 `自定义tabbar`,需要配置 `customTabbarList`,每个 `item` 需要配置 `path`、`text`、`icon` 、`iconType` 等属性(如果是 `image` 图片还需要配置2种图片)。
+
+## 文件说明
+
+`config.ts` 专门配置 `nativeTabbarList` 和 `customTabbarList` 的相关信息,请按照文件里面的注释配置相关项。
+
+使用 `原生tabbar` 时,不需要关心下面2个文件:
+
+- `store.ts` ,专门给 `自定义 tabbar` 提供状态管理,代码几乎不需要修改。
+- `index.vue` ,专门给 `自定义 tabbar` 提供渲染逻辑,代码可以稍微修改,以符合自己的需求。
+
+## 自定义tabbar的不同类型的配置
+
+- uiLib 图标
+
+ ```js
+ {
+ // ... 其他配置
+ "iconType": "uniUi",
+ "icon": "home",
+ }
+ ```
+
+- unocss 图标
+
+ ```js
+ {
+ // ... 其他配置
+ // 注意 unocss 图标需要如下处理:(二选一)
+ // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
+ // 2)配置到 unocss.config.ts 的 safelist 中
+ iconType: 'unocss',
+ icon: 'i-carbon-code',
+ }
+ ```
+
+- iconfont 图标
+
+ ```js
+ {
+ // ... 其他配置
+ // 注意 iconfont 图标需要额外加上 'iconfont',如下
+ iconType: 'iconfont',
+ icon: 'iconfont icon-my',
+ }
+ ```
+
+- image 本地图片
+
+ ```js
+ {
+ // ... 其他配置
+ // 使用 ‘image’时,需要配置 icon + iconActive 2张图片(不推荐)
+ // 既然已经用了自定义tabbar了,就不建议用图片了,所以不推荐
+ iconType: 'image',
+ icon: '/static/tabbar/home.png',
+ iconActive: '/static/tabbar/homeHL.png',
+ }
+ ```
diff --git a/mp-sc-frontend/src/tabbar/TabbarItem.test.ts b/mp-sc-frontend/src/tabbar/TabbarItem.test.ts
new file mode 100644
index 0000000..d6118c7
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/TabbarItem.test.ts
@@ -0,0 +1,69 @@
+import type { CustomTabBarItem } from './types'
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import TabbarItem from './TabbarItem.vue'
+
+// mock tabbar store,避免 uni.getStorageSync 在模块加载时执行
+vi.mock('./store', () => ({
+ tabbarStore: { curIdx: 0 },
+}))
+
+const baseItem: CustomTabBarItem = {
+ text: '首页',
+ pagePath: 'pages/index/index',
+ iconType: 'unocss',
+ icon: 'i-carbon-home',
+}
+
+describe('TabbarItem', () => {
+ let wrapper: ReturnType
+
+ afterEach(() => {
+ wrapper?.unmount()
+ })
+
+ it('渲染 text 文本', () => {
+ wrapper = mount(TabbarItem, {
+ props: { item: baseItem, index: 0 },
+ })
+ expect(wrapper.text()).toContain('首页')
+ })
+
+ it('isBulge=true 时不渲染文本', () => {
+ wrapper = mount(TabbarItem, {
+ props: { item: baseItem, index: 0, isBulge: true },
+ })
+ expect(wrapper.text()).not.toContain('首页')
+ })
+
+ it('iconType=unocss 时渲染图标 class', () => {
+ wrapper = mount(TabbarItem, {
+ props: { item: baseItem, index: 0 },
+ })
+ expect(wrapper.html()).toContain('i-carbon-home')
+ })
+
+ it('badge=dot 时渲染小红点(包含 rounded-full 样式)', () => {
+ const item: CustomTabBarItem = { ...baseItem, badge: 'dot' }
+ wrapper = mount(TabbarItem, {
+ props: { item, index: 0 },
+ })
+ expect(wrapper.html()).toContain('rounded-full')
+ })
+
+ it('badge 为数字时渲染数字角标', () => {
+ const item: CustomTabBarItem = { ...baseItem, badge: 5 }
+ wrapper = mount(TabbarItem, {
+ props: { item, index: 0 },
+ })
+ expect(wrapper.text()).toContain('5')
+ })
+
+ it('badge > 99 时显示 99+', () => {
+ const item: CustomTabBarItem = { ...baseItem, badge: 100 }
+ wrapper = mount(TabbarItem, {
+ props: { item, index: 0 },
+ })
+ expect(wrapper.text()).toContain('99+')
+ })
+})
diff --git a/mp-sc-frontend/src/tabbar/TabbarItem.vue b/mp-sc-frontend/src/tabbar/TabbarItem.vue
new file mode 100644
index 0000000..b3e9453
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/TabbarItem.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+ {{ item.badge > 99 ? '99+' : item.badge }}
+
+
+
+
+
diff --git a/mp-sc-frontend/src/tabbar/config.ts b/mp-sc-frontend/src/tabbar/config.ts
new file mode 100644
index 0000000..e31eb23
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/config.ts
@@ -0,0 +1,68 @@
+import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
+import type { CustomTabBarItem, NativeTabBarItem } from './types'
+
+export const TABBAR_STRATEGY_MAP = {
+ NO_TABBAR: 0,
+ NATIVE_TABBAR: 1,
+ CUSTOM_TABBAR: 2,
+}
+
+// 使用原生 tabbar(兼容性最好)
+export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.NATIVE_TABBAR
+
+export const nativeTabbarList: NativeTabBarItem[] = [
+ {
+ iconPath: 'static/tabbar/home.png',
+ selectedIconPath: 'static/tabbar/homeHL.png',
+ pagePath: 'pages/index/index',
+ text: '首页',
+ },
+ {
+ iconPath: 'static/tabbar/personal.png',
+ selectedIconPath: 'static/tabbar/personalHL.png',
+ pagePath: 'pages/me/me',
+ text: '我的',
+ },
+]
+
+export const customTabbarList: CustomTabBarItem[] = [
+ {
+ text: '首页',
+ pagePath: 'pages/index/index',
+ iconType: 'unocss',
+ icon: 'i-carbon-home',
+ },
+ {
+ text: '我的',
+ pagePath: 'pages/me/me',
+ iconType: 'unocss',
+ icon: 'i-carbon-user',
+ },
+]
+
+export const tabbarCacheEnable
+ = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy)
+
+export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy)
+
+export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR
+
+const _tabbarList = customTabbarEnable ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList
+export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList
+
+export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR
+
+const _tabbar: TabBar = {
+ custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
+ color: '#999999',
+ selectedColor: '#007AFF',
+ backgroundColor: '#FFFFFF',
+ borderStyle: 'black',
+ height: 50,
+ fontSize: 10,
+ iconWidth: 24,
+ spacing: 3,
+ list: _tabbarList as unknown as TabBar['list'],
+}
+
+export const tabBar = tabbarCacheEnable ? _tabbar : undefined
diff --git a/mp-sc-frontend/src/tabbar/index.vue b/mp-sc-frontend/src/tabbar/index.vue
new file mode 100644
index 0000000..089ef34
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/index.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mp-sc-frontend/src/tabbar/store.ts b/mp-sc-frontend/src/tabbar/store.ts
new file mode 100644
index 0000000..8274fc0
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/store.ts
@@ -0,0 +1,118 @@
+import type { CustomTabBarItem, CustomTabBarItemBadge } from './types'
+import { computed, reactive } from 'vue'
+import { useUserStore } from '@/store/user'
+
+import { tabbarList as _tabbarList, selectedTabbarStrategy, TABBAR_STRATEGY_MAP } from './config'
+
+/** tabbarList 里面的 path 从 pages.config.ts 得到 */
+const baseTabbarList = reactive(_tabbarList.map(item => ({
+ ...item,
+ pagePath: item.pagePath.startsWith('/') ? item.pagePath : `/${item.pagePath}`, // 统一成 '/' 开头的路径
+})))
+
+const userRoles = computed(() => {
+ const userStore = useUserStore()
+ const role = userStore.roleCode
+ return role ? [role] : []
+})
+
+const tabbarList = computed(() => {
+ const roles = userRoles.value
+ if (roles.length === 0) {
+ return baseTabbarList.filter(item => !item.roles || item.roles.length === 0)
+ }
+ return baseTabbarList.filter(item => !item.roles || item.roles.length === 0 || item.roles.some(role => roles.includes(role)))
+})
+
+export function isPageTabbar(path: string) {
+ if (selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NO_TABBAR) {
+ return false
+ }
+ const _path = normalizeRoutePath(path)
+ return _path === '/' || tabbarList.value.some(item => item.pagePath === _path)
+}
+
+export function normalizeRoutePath(path?: string) {
+ if (!path) {
+ return ''
+ }
+ const _path = path.split('?')[0]
+ return _path.startsWith('/') ? _path : `/${_path}`
+}
+
+function getCurrentPagePath() {
+ const pages = getCurrentPages()
+ const currentPage = pages[pages.length - 1]
+ return normalizeRoutePath(currentPage?.route)
+}
+
+function findTabbarIndexByPath(path?: string) {
+ const normalizedPath = normalizeRoutePath(path)
+ if (normalizedPath === '/') {
+ return 0
+ }
+ return tabbarList.value.findIndex(item => item.pagePath === normalizedPath)
+}
+
+/**
+ * 自定义 tabbar 的状态管理,原生 tabbar 无需关注本文件
+ * tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面
+ * 使用reactive简单状态,而不是 pinia 全局状态
+ */
+const tabbarStore = reactive({
+ curIdx: uni.getStorageSync('app-tabbar-index') || 0,
+ prevIdx: uni.getStorageSync('app-tabbar-index') || 0,
+ setCurIdx(idx: number) {
+ this.curIdx = idx
+ uni.setStorageSync('app-tabbar-index', idx)
+ },
+ setTabbarItemBadge(idx: number, badge: CustomTabBarItemBadge) {
+ const list = tabbarList.value
+ if (list[idx]) {
+ list[idx].badge = badge
+ }
+ },
+ setAutoCurIdx(path: string) {
+ const list = tabbarList.value
+ if (list.length === 0) {
+ this.setCurIdx(0)
+ return
+ }
+
+ const index = findTabbarIndexByPath(path)
+ if (index >= 0) {
+ this.setCurIdx(index)
+ return
+ }
+
+ if (this.curIdx < 0 || this.curIdx >= list.length) {
+ this.setCurIdx(0)
+ }
+ },
+ syncCurIdxByCurrentPage() {
+ const currentPath = getCurrentPagePath()
+ if (currentPath) {
+ this.setAutoCurIdx(currentPath)
+ }
+ },
+ syncCurIdxByCurrentPageAsync() {
+ setTimeout(() => {
+ this.syncCurIdxByCurrentPage()
+ }, 0)
+ },
+ isCurrentRouteTabbarItem(index: number) {
+ const item = tabbarList.value[index]
+ if (!item) {
+ return false
+ }
+ return findTabbarIndexByPath(getCurrentPagePath()) === index
+ },
+ restorePrevIdx() {
+ if (this.prevIdx === this.curIdx)
+ return
+ this.setCurIdx(this.prevIdx)
+ this.prevIdx = uni.getStorageSync('app-tabbar-index') || 0
+ },
+})
+
+export { tabbarList, tabbarStore }
diff --git a/mp-sc-frontend/src/tabbar/types.ts b/mp-sc-frontend/src/tabbar/types.ts
new file mode 100644
index 0000000..cfa6a6e
--- /dev/null
+++ b/mp-sc-frontend/src/tabbar/types.ts
@@ -0,0 +1,37 @@
+import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
+import type { UserRole } from '@/api/types/login'
+import type { RemoveLeadingSlashFromUnion } from '@/typings'
+
+/**
+ * 原生 tabbar 的单个选项配置
+ */
+export type NativeTabBarItem = TabBar['list'][number] & {
+ pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
+}
+
+/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */
+export type CustomTabBarItemBadge = number | 'dot'
+
+/** 自定义 tabbar 的单个选项配置 */
+export interface CustomTabBarItem {
+ text: string
+ pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
+ /** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
+ iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image'
+ /**
+ * icon 的路径
+ * - uiLib: wot-design-uni 图标的 icon prop
+ * - unocss: unocss 图标的类名
+ * - iconfont: iconfont 图标的类名
+ * - image: 图片的路径
+ */
+ icon: string
+ /** 只有在 image 模式下才需要,传递的是高亮的图片 */
+ iconActive?: string
+ /** badge 显示一个数字或 小红点 */
+ badge?: CustomTabBarItemBadge
+ /** 是否是中间的鼓包tabbarItem */
+ isBulge?: boolean
+ // roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见
+ roles?: UserRole[]
+}
diff --git a/mp-sc-frontend/src/test-setup.ts b/mp-sc-frontend/src/test-setup.ts
new file mode 100644
index 0000000..5a98b0c
--- /dev/null
+++ b/mp-sc-frontend/src/test-setup.ts
@@ -0,0 +1,51 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, vi } from 'vitest'
+
+// 每个测试前重置 Pinia 实例,避免状态在测试间泄漏
+beforeEach(() => {
+ // 不注册 pinia-plugin-persistedstate,测试只验证 store 逻辑,不验证持久化行为
+ setActivePinia(createPinia())
+ // 重置所有 mock 的调用记录(保留实现,仅清空 .mock.calls 等)
+ vi.clearAllMocks()
+})
+
+// 全局 mock uni 对象(jsdom 中无此全局,uni-app 特有)
+const uniMock = {
+ showToast: vi.fn(),
+ hideToast: vi.fn(),
+ showLoading: vi.fn(),
+ hideLoading: vi.fn(),
+ showModal: vi.fn(),
+ navigateTo: vi.fn(),
+ redirectTo: vi.fn(),
+ navigateBack: vi.fn(),
+ switchTab: vi.fn(),
+ reLaunch: vi.fn(),
+ // tabbar/store.ts 在模块初始化时调用 getStorageSync,返回 null 确保不影响初始状态
+ getStorageSync: vi.fn().mockReturnValue(null),
+ setStorageSync: vi.fn(),
+ removeStorageSync: vi.fn(),
+ getStorage: vi.fn(),
+ setStorage: vi.fn(),
+ removeStorage: vi.fn(),
+ request: vi.fn(),
+ uploadFile: vi.fn(),
+ chooseImage: vi.fn(),
+ getSystemInfoSync: vi.fn().mockReturnValue({ platform: 'devtools' }),
+ getSystemInfo: vi.fn(),
+ onNetworkStatusChange: vi.fn(),
+ getNetworkType: vi.fn(),
+}
+
+Object.defineProperty(globalThis, 'uni', {
+ value: uniMock,
+ writable: true,
+ configurable: true,
+})
+
+// getCurrentPages 是 uni-app 的全局函数(不在 uni 对象上)
+Object.defineProperty(globalThis, 'getCurrentPages', {
+ value: vi.fn().mockReturnValue([{ route: '/pages/index/index' }]),
+ writable: true,
+ configurable: true,
+})
diff --git a/mp-sc-frontend/src/typings.d.ts b/mp-sc-frontend/src/typings.d.ts
new file mode 100644
index 0000000..9732451
--- /dev/null
+++ b/mp-sc-frontend/src/typings.d.ts
@@ -0,0 +1,171 @@
+// 全局要用的类型放到这里
+
+declare global {
+ interface IResData {
+ code: number
+ msg: string
+ data: T
+ }
+
+ // uni.uploadFile文件上传参数
+ interface IUniUploadFileOptions {
+ file?: File
+ files?: UniApp.UploadFileOptionFiles[]
+ filePath?: string
+ name?: string
+ formData?: any
+ }
+
+ interface IUserInfo {
+ nickname?: string
+ avatar?: string
+ /** 微信的 openid,非微信没有这个字段 */
+ openid?: string
+ }
+
+ interface IUserToken {
+ token: string
+ refreshToken?: string
+ refreshExpire?: number
+ }
+}
+
+// 扩展 @uni-helper/vite-plugin-uni-pages 的 definePage 参数类型
+declare module '@uni-helper/vite-plugin-uni-pages' {
+ interface UserPageMeta {
+ /**
+ * 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
+ *
+ * 尽量保证一个项目 只有一个 这个配置,如果有多个,会按照字母顺序来排列,最终可能不是您想要的效果。
+ */
+ type?: 'home'
+ /**
+ * 页面布局类型, 模板默认只有 default, 如果在 src/layouts 下新增了 layout, 可以扩展当前属性
+ * @default 'default'
+ *
+ * 当前属性供 https://github.com/uni-helper/vite-plugin-uni-layouts 插件使用
+ */
+ layout?: 'default' | false
+ /**
+ * 是否从需要登录的路径中排除
+ *
+ * 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹
+ */
+ excludeLoginPath?: boolean
+ }
+}
+
+// patch uni 类型
+// 1. 补全 uni.hideToast() 的 options 类型
+// 2. 补全 uni.hideLoading() 的 options 类型
+// 3. 使用方式见:https://github.com/unibest-tech/unibest/pull/241
+declare global {
+ declare namespace UniNamespace {
+ /** 接口调用结束的回调函数(调用成功、失败都会执行) */
+ type HideLoadingCompleteCallback = (res: GeneralCallbackResult) => void
+ /** 接口调用失败的回调函数 */
+ type HideLoadingFailCallback = (res: GeneralCallbackResult) => void
+ /** 接口调用成功的回调函数 */
+ type HideLoadingSuccessCallback = (res: GeneralCallbackResult) => void
+
+ interface HideLoadingOption {
+ /** 接口调用结束的回调函数(调用成功、失败都会执行) */
+ complete?: HideLoadingCompleteCallback
+ /** 接口调用失败的回调函数 */
+ fail?: HideLoadingFailCallback
+ test: UniNamespace.GeneralCallbackResult
+ /**
+ * 微信小程序:需要基础库: `2.22.1`
+ *
+ * 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性
+ */
+ noConflict?: boolean
+ /** 接口调用成功的回调函数 */
+ success?: HideLoadingSuccessCallback
+ }
+
+ // ----------------------------------------------------------
+
+ /** 接口调用结束的回调函数(调用成功、失败都会执行) */
+ type HideToastCompleteCallback = (res: GeneralCallbackResult) => void
+ /** 接口调用失败的回调函数 */
+ type HideToastFailCallback = (res: GeneralCallbackResult) => void
+ /** 接口调用成功的回调函数 */
+ type HideToastSuccessCallback = (res: GeneralCallbackResult) => void
+ interface HideToastOption {
+ /** 接口调用结束的回调函数(调用成功、失败都会执行) */
+ complete?: HideToastCompleteCallback
+ /** 接口调用失败的回调函数 */
+ fail?: HideToastFailCallback
+ /**
+ * 微信小程序:需要基础库: `2.22.1`
+ *
+ * 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性
+ */
+ noConflict?: boolean
+ /** 接口调用成功的回调函数 */
+ success?: HideToastSuccessCallback
+ }
+ }
+ interface Uni {
+ /**
+ * 隐藏 loading 提示框
+ *
+ * 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hideloading](http://uniapp.dcloud.io/api/ui/prompt?id=hideloading)
+ * @example ```typescript
+ * uni.showLoading({
+ * title: '加载中'
+ * });
+ *
+ * setTimeout(function () {
+ * uni.hideLoading();
+ * }, 2000);
+ *
+ * ```
+ * @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hideloading)
+ * @uniPlatform {
+ * "app": {
+ * "android": {
+ * "osVer": "4.4.4",
+ * "uniVer": "√",
+ * "unixVer": "3.9.0"
+ * },
+ * "ios": {
+ * "osVer": "9.0",
+ * "uniVer": "√",
+ * "unixVer": "3.9.0"
+ * }
+ * }
+ * }
+ */
+ // eslint-disable-next-line ts/method-signature-style
+ hideLoading(options?: T): void
+ /**
+ * 隐藏消息提示框
+ *
+ * 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast](http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast)
+ * @example ```typescript
+ * uni.hideToast();
+ * ```
+ * @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hidetoast)
+ * @uniPlatform {
+ * "app": {
+ * "android": {
+ * "osVer": "4.4.4",
+ * "uniVer": "√",
+ * "unixVer": "3.9.0"
+ * },
+ * "ios": {
+ * "osVer": "9.0",
+ * "uniVer": "√",
+ * "unixVer": "3.9.0"
+ * }
+ * }
+ * }
+ */
+ // eslint-disable-next-line ts/method-signature-style
+ hideToast(options?: T): void
+ }
+}
+
+export {} // 防止模块污染
diff --git a/mp-sc-frontend/src/typings.ts b/mp-sc-frontend/src/typings.ts
new file mode 100644
index 0000000..fefe1cb
--- /dev/null
+++ b/mp-sc-frontend/src/typings.ts
@@ -0,0 +1,21 @@
+// 枚举定义
+
+export enum TestEnum {
+ A = '1',
+ B = '2',
+}
+
+// uni.uploadFile文件上传参数
+export interface IUniUploadFileOptions {
+ file?: File
+ files?: UniApp.UploadFileOptionFiles[]
+ filePath?: string
+ name?: string
+ formData?: any
+}
+
+/** 工具类型:删除字符串开头的第一个斜杠 */
+export type RemoveLeadingSlash = S extends `/${infer Rest}` ? Rest : S
+
+/** 工具类型:删除联合类型中每个字符串的第一个斜杠 */
+export type RemoveLeadingSlashFromUnion = T extends any ? RemoveLeadingSlash : never
diff --git a/mp-sc-frontend/src/uni.scss b/mp-sc-frontend/src/uni.scss
new file mode 100644
index 0000000..21b9e5f
--- /dev/null
+++ b/mp-sc-frontend/src/uni.scss
@@ -0,0 +1,77 @@
+/* stylelint-disable comment-empty-line-before */
+/**
+ * 这里是uni-app内置的常用样式变量
+ *
+ * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
+ * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
+ *
+ */
+
+/**
+ * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
+ *
+ * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
+ */
+
+/* 颜色变量 */
+
+/* 行为相关颜色 */
+$uni-color-primary: #007aff;
+$uni-color-success: #4cd964;
+$uni-color-warning: #f0ad4e;
+$uni-color-error: #dd524d;
+
+/* 文字基本颜色 */
+$uni-text-color: #333; // 基本色
+$uni-text-color-inverse: #fff; // 反色
+$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
+$uni-text-color-placeholder: #808080;
+$uni-text-color-disable: #c0c0c0;
+
+/* 背景颜色 */
+$uni-bg-color: #fff;
+$uni-bg-color-grey: #f8f8f8;
+$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
+$uni-bg-color-mask: rgb(0 0 0 / 40%); // 遮罩颜色
+
+/* 边框颜色 */
+$uni-border-color: #c8c7cc;
+
+/* 尺寸变量 */
+
+/* 文字尺寸 */
+$uni-font-size-sm: 12px;
+$uni-font-size-base: 14px;
+$uni-font-size-lg: 16;
+
+/* 图片尺寸 */
+$uni-img-size-sm: 20px;
+$uni-img-size-base: 26px;
+$uni-img-size-lg: 40px;
+
+/* Border Radius */
+$uni-border-radius-sm: 2px;
+$uni-border-radius-base: 3px;
+$uni-border-radius-lg: 6px;
+$uni-border-radius-circle: 50%;
+
+/* 水平间距 */
+$uni-spacing-row-sm: 5px;
+$uni-spacing-row-base: 10px;
+$uni-spacing-row-lg: 15px;
+
+/* 垂直间距 */
+$uni-spacing-col-sm: 4px;
+$uni-spacing-col-base: 8px;
+$uni-spacing-col-lg: 12px;
+
+/* 透明度 */
+$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
+
+/* 文章场景相关 */
+$uni-color-title: #2c405a; // 文章标题颜色
+$uni-font-size-title: 20px;
+$uni-color-subtitle: #555; // 二级标题颜色
+$uni-font-size-subtitle: 18px;
+$uni-color-paragraph: #3f536e; // 文章段落颜色
+$uni-font-size-paragraph: 15px;
diff --git a/mp-sc-frontend/src/uni_modules/.gitkeep b/mp-sc-frontend/src/uni_modules/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/mp-sc-frontend/src/utils/constants.ts b/mp-sc-frontend/src/utils/constants.ts
new file mode 100644
index 0000000..64c3352
--- /dev/null
+++ b/mp-sc-frontend/src/utils/constants.ts
@@ -0,0 +1,100 @@
+// 预约状态
+export const APPOINTMENT_STATUS = {
+ PENDING_EMPLOYEE: 0, // 待员工审核
+ EMPLOYEE_APPROVED: 1, // 员工通过
+ EMPLOYEE_REJECTED: 2, // 员工拒绝
+ PENDING_LEADER: 3, // 待领导审核
+ APPROVED: 4, // 已通过
+ CANCELLED: 5, // 已取消
+ COMPLETED: 6, // 已完成
+ LEADER_REJECTED: 7, // 领导拒绝
+} as const
+
+// 预约状态映射
+export const APPOINTMENT_STATUS_MAP: Record = {
+ 0: { text: '审核中', color: '#2979ff' },
+ 1: { text: '已通过', color: '#18bc37' },
+ 2: { text: '已拒绝', color: '#dd524d' },
+ 3: { text: '已取消', color: '#999999' },
+}
+
+// 来访目的(wd-action-sheet 要求 actions 包含 name 属性)
+export const VISIT_TYPES = [
+ { name: '应聘', value: '应聘' },
+ { name: '物流', value: '物流' },
+ { name: '客户', value: '客户' },
+ { name: '供应商', value: '供应商' },
+ { name: '银行人员', value: '银行人员' },
+ { name: '券商', value: '券商' },
+ { name: '外协施工方', value: '外协施工方' },
+ { name: '访客', value: '访客' },
+ { name: '其他', value: '其他' },
+]
+
+// 性别
+export const GENDER_OPTIONS = [
+ { label: '男', value: 1 },
+ { label: '女', value: 2 },
+]
+
+// 证件类型
+export const ID_TYPES = [
+ { label: '身份证', value: '身份证' },
+ { label: '护照', value: '护照' },
+ { label: '驾驶证', value: '驾驶证' },
+ { label: '港澳通行证', value: '港澳通行证' },
+]
+
+// 到访区域
+export const VISIT_AREAS = [
+ { label: 'A区域', value: 'A区域' },
+ { label: 'B区域', value: 'B区域' },
+ { label: 'C区域', value: 'C区域' },
+ { label: 'D区域', value: 'D区域' },
+ { label: '办公楼', value: '办公楼' },
+ { label: '生产区', value: '生产区' },
+ { label: '研发中心', value: '研发中心' },
+]
+
+// 车辆品牌
+export const VEHICLE_BRANDS = [
+ { label: '大众', value: '大众' },
+ { label: '丰田', value: '丰田' },
+ { label: '本田', value: '本田' },
+ { label: '日产', value: '日产' },
+ { label: '宝马', value: '宝马' },
+ { label: '奔驰', value: '奔驰' },
+ { label: '奥迪', value: '奥迪' },
+ { label: '比亚迪', value: '比亚迪' },
+ { label: '吉利', value: '吉利' },
+ { label: '长城', value: '长城' },
+ { label: '长安', value: '长安' },
+ { label: '五菱', value: '五菱' },
+ { label: '其他', value: '其他' },
+]
+
+// 车辆颜色
+export const VEHICLE_COLORS = ['白色', '黑色', '银色', '灰色', '红色', '蓝色', '绿色', '黄色', '棕色', '橙色', '紫色', '粉色']
+
+// 角色申请状态
+export const APPLICATION_STATUS = {
+ PENDING: 0,
+ APPROVED: 1,
+ REJECTED: 2,
+} as const
+
+export const APPLICATION_STATUS_MAP: Record = {
+ 0: { text: '待审核', color: '#2979ff' },
+ 1: { text: '已通过', color: '#18bc37' },
+ 2: { text: '已拒绝', color: '#dd524d' },
+}
+
+// ============ 微信订阅消息模板 ID ============
+/** 来访预约取消通知:访客姓名、访客电话、预约时间、来访事由、取消原因 */
+export const TMPL_CANCEL = 'ZSbJDf3HzOjAhbN3IRvyljS-ffmR9yzxGdt_yEwYxHI'
+
+/** 访客预约通知:访客姓名、来访时间、拜访事由、来访区域、备注 */
+export const TMPL_VISIT = '0lyq-SVrpFhRIsmxovsGsX02Olye4LUindHVUx_YtWo'
+
+/** 访客申请结果通知:申请结果、访问企业、访问时间、被访人、访问区域 */
+export const TMPL_RESULT = '7tFHeTmubiHMhAaJGZB5lMLqlhU1VrUUgSjYp1Z05cY'
diff --git a/mp-sc-frontend/src/utils/debounce.test.ts b/mp-sc-frontend/src/utils/debounce.test.ts
new file mode 100644
index 0000000..8e71356
--- /dev/null
+++ b/mp-sc-frontend/src/utils/debounce.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { debounce } from './debounce'
+
+describe('debounce', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('trailing edge(默认):多次调用后只触发一次,使用最后一次的参数', () => {
+ const fn = vi.fn()
+ const debouncedFn = debounce(fn, 100)
+
+ debouncedFn('first')
+ debouncedFn('second')
+ debouncedFn('last')
+
+ expect(fn).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(100)
+
+ expect(fn).toHaveBeenCalledTimes(1)
+ expect(fn).toHaveBeenCalledWith('last')
+ })
+
+ it('cancel:取消后计时器到期不执行', () => {
+ const fn = vi.fn()
+ const debouncedFn = debounce(fn, 100)
+
+ debouncedFn()
+ debouncedFn.cancel()
+
+ vi.advanceTimersByTime(100)
+
+ expect(fn).not.toHaveBeenCalled()
+ })
+
+ it('flush:立即执行待执行的调用并传递参数', () => {
+ const fn = vi.fn()
+ const debouncedFn = debounce(fn, 100)
+
+ debouncedFn('arg1')
+ debouncedFn.flush()
+
+ expect(fn).toHaveBeenCalledWith('arg1')
+ })
+
+ it('leading edge:第一次调用立即执行', () => {
+ const fn = vi.fn()
+ const debouncedFn = debounce(fn, 100, { edges: ['leading'] })
+
+ debouncedFn()
+
+ expect(fn).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mp-sc-frontend/src/utils/debounce.ts b/mp-sc-frontend/src/utils/debounce.ts
new file mode 100644
index 0000000..c13f470
--- /dev/null
+++ b/mp-sc-frontend/src/utils/debounce.ts
@@ -0,0 +1,166 @@
+// fork from https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts
+// 文档可查看:https://es-toolkit.dev/reference/function/debounce.html
+// 如需要 throttle 功能,可 copy https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts
+
+interface DebounceOptions {
+ /**
+ * An optional AbortSignal to cancel the debounced function.
+ */
+ signal?: AbortSignal
+
+ /**
+ * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both.
+ * If `edges` includes "leading", the function will be invoked at the start of the delay period.
+ * If `edges` includes "trailing", the function will be invoked at the end of the delay period.
+ * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period.
+ * @default ["trailing"]
+ */
+ edges?: Array<'leading' | 'trailing'>
+}
+
+export interface DebouncedFunction void> {
+ (...args: Parameters): void
+
+ /**
+ * Schedules the execution of the debounced function after the specified debounce delay.
+ * This method resets any existing timer, ensuring that the function is only invoked
+ * after the delay has elapsed since the last call to the debounced function.
+ * It is typically called internally whenever the debounced function is invoked.
+ *
+ * @returns {void}
+ */
+ schedule: () => void
+
+ /**
+ * Cancels any pending execution of the debounced function.
+ * This method clears the active timer and resets any stored context or arguments.
+ */
+ cancel: () => void
+
+ /**
+ * Immediately invokes the debounced function if there is a pending execution.
+ * This method executes the function right away if there is a pending execution.
+ */
+ flush: () => void
+}
+
+/**
+ * Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds
+ * have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel`
+ * method to cancel any pending execution.
+ *
+ * @template F - The type of function.
+ * @param {F} func - The function to debounce.
+ * @param {number} debounceMs - The number of milliseconds to delay.
+ * @param {DebounceOptions} options - The options object
+ * @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
+ * @returns A new debounced function with a `cancel` method.
+ *
+ * @example
+ * const debouncedFunction = debounce(() => {
+ * console.log('Function executed');
+ * }, 1000);
+ *
+ * // Will log 'Function executed' after 1 second if not called again in that time
+ * debouncedFunction();
+ *
+ * // Will not log anything as the previous call is canceled
+ * debouncedFunction.cancel();
+ *
+ * // With AbortSignal
+ * const controller = new AbortController();
+ * const signal = controller.signal;
+ * const debouncedWithSignal = debounce(() => {
+ * console.log('Function executed');
+ * }, 1000, { signal });
+ *
+ * debouncedWithSignal();
+ *
+ * // Will cancel the debounced function call
+ * controller.abort();
+ */
+export function debounce void>(
+ func: F,
+ debounceMs: number,
+ { signal, edges }: DebounceOptions = {},
+): DebouncedFunction {
+ let pendingThis: any
+ let pendingArgs: Parameters | null = null
+
+ const leading = edges != null && edges.includes('leading')
+ const trailing = edges == null || edges.includes('trailing')
+
+ const invoke = () => {
+ if (pendingArgs !== null) {
+ func.apply(pendingThis, pendingArgs)
+ pendingThis = undefined
+ pendingArgs = null
+ }
+ }
+
+ const onTimerEnd = () => {
+ if (trailing) {
+ invoke()
+ }
+
+ // eslint-disable-next-line ts/no-use-before-define
+ cancel()
+ }
+
+ let timeoutId: ReturnType | null = null
+
+ const schedule = () => {
+ if (timeoutId != null) {
+ clearTimeout(timeoutId)
+ }
+
+ timeoutId = setTimeout(() => {
+ timeoutId = null
+
+ onTimerEnd()
+ }, debounceMs)
+ }
+
+ const cancelTimer = () => {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+ }
+
+ const cancel = () => {
+ cancelTimer()
+ pendingThis = undefined
+ pendingArgs = null
+ }
+
+ const flush = () => {
+ invoke()
+ }
+
+ const debounced = function (this: any, ...args: Parameters) {
+ if (signal?.aborted) {
+ return
+ }
+
+ // eslint-disable-next-line ts/no-this-alias
+ pendingThis = this
+ pendingArgs = args
+
+ const isFirstCall = timeoutId == null
+
+ schedule()
+
+ if (leading && isFirstCall) {
+ invoke()
+ }
+ }
+
+ debounced.schedule = schedule
+ debounced.cancel = cancel
+ debounced.flush = flush
+
+ signal?.addEventListener('abort', cancel, { once: true })
+
+ return debounced
+}
diff --git a/mp-sc-frontend/src/utils/index.ts b/mp-sc-frontend/src/utils/index.ts
new file mode 100644
index 0000000..cdecd8e
--- /dev/null
+++ b/mp-sc-frontend/src/utils/index.ts
@@ -0,0 +1,179 @@
+import type { PageMetaDatum, SubPackages } from '@uni-helper/vite-plugin-uni-pages'
+import { isMpWeixin } from '@uni-helper/uni-env'
+import { pages, subPackages } from '@/pages.json'
+import { isPageTabbar } from '@/tabbar/store'
+
+export type PageInstance = Page.PageInstance & { $page: Page.PageInstance & { fullPath: string } }
+
+export function getLastPage() {
+ // getCurrentPages() 至少有1个元素,所以不再额外判断
+ // const lastPage = getCurrentPages().at(-1)
+ // 上面那个在低版本安卓中打包会报错,所以改用下面这个【虽然我加了 src/interceptions/prototype.ts,但依然报错】
+ const pages = getCurrentPages()
+ return pages[pages.length - 1] as PageInstance
+}
+
+/**
+ * 获取当前页面路由的 path 路径和 redirectPath 路径
+ * path 如 '/pages/login/login'
+ * redirectPath 如 '/pages/demo/base/route-interceptor'
+ */
+export function currRoute() {
+ const lastPage = getLastPage() as PageInstance
+ if (!lastPage) {
+ return {
+ path: '',
+ query: {},
+ }
+ }
+ const currRoute = lastPage.$page
+ // console.log('lastPage.$page:', currRoute)
+ // console.log('lastPage.$page.fullpath:', currRoute.fullPath)
+ // console.log('lastPage.$page.options:', currRoute.options)
+ // console.log('lastPage.options:', (lastPage as any).options)
+ // 经过多端测试,只有 fullPath 靠谱,其他都不靠谱
+ const { fullPath } = currRoute
+ // console.log(fullPath)
+ // eg: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor (小程序)
+ // eg: /pages/login/login?redirect=%2Fpages%2Froute-interceptor%2Findex%3Fname%3Dfeige%26age%3D30(h5)
+ return parseUrlToObj(fullPath)
+}
+
+export function ensureDecodeURIComponent(url: string) {
+ if (url.startsWith('%')) {
+ return ensureDecodeURIComponent(decodeURIComponent(url))
+ }
+ return url
+}
+/**
+ * 解析 url 得到 path 和 query
+ * 比如输入url: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor
+ * 输出: {path: /pages/login/login, query: {redirect: /pages/demo/base/route-interceptor}}
+ */
+export function parseUrlToObj(url: string) {
+ const [path, queryStr] = url.split('?')
+ // console.log(path, queryStr)
+
+ if (!queryStr) {
+ return {
+ path,
+ query: {},
+ }
+ }
+ const query: Record = {}
+ queryStr.split('&').forEach((item) => {
+ const [key, value] = item.split('=')
+ // console.log(key, value)
+ query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y
+ })
+ return { path, query }
+}
+/**
+ * 得到所有的需要登录的 pages,包括主包和分包的
+ * 这里设计得通用一点,可以传递 key 作为判断依据,默认是 excludeLoginPath, 与 route-block 配对使用
+ * 如果没有传 key,则表示所有的 pages,如果传递了 key, 则表示通过 key 过滤
+ */
+export function getAllPages(key?: string) {
+ // 这里处理主包
+ const mainPages = (pages as PageMetaDatum[])
+ .filter(page => !key || page[key])
+ .map(page => ({
+ ...page,
+ path: `/${page.path}`,
+ }))
+
+ // 这里处理分包
+ const subPages: PageMetaDatum[] = []
+ ;(subPackages as SubPackages).forEach((subPageObj) => {
+ // console.log(subPageObj)
+ const { root } = subPageObj
+
+ subPageObj.pages
+ .filter(page => !key || page[key])
+ .forEach((page) => {
+ subPages.push({
+ ...page,
+ path: `/${root}/${page.path}`,
+ })
+ })
+ })
+ const result = [...mainPages, ...subPages]
+ // console.log(`getAllPages by ${key} result: `, result)
+ return result
+}
+
+export function getCurrentPageI18nKey() {
+ const routeObj = currRoute()
+
+ let currPage = (pages as PageMetaDatum[]).find(page => `/${page.path}` === routeObj.path)
+ if (!currPage) {
+ // 在主包中找不到对应的页面,则在分包中找
+ const allSubPages: PageMetaDatum[] = []
+ subPackages?.forEach((config) => {
+ config.pages?.forEach((cur) => {
+ allSubPages.push({
+ ...cur,
+ path: `/${config.root}/${cur.path}`,
+ })
+ })
+ })
+ currPage = allSubPages.find(page => page.path === routeObj.path)
+ if (!currPage) {
+ console.warn('路由不正确')
+ return ''
+ }
+ }
+ console.log(currPage)
+ console.log(currPage.style.navigationBarTitleText)
+ return currPage.style?.navigationBarTitleText || ''
+}
+
+export function isCurrentPageTabbar() {
+ const { path } = currRoute()
+ return isPageTabbar(path)
+}
+
+/**
+ * 根据微信小程序当前环境,判断应该获取的 baseUrl
+ */
+export function getEnvBaseUrl() {
+ // 请求基准地址
+ let baseUrl = import.meta.env.VITE_SERVER_BASEURL
+
+ // # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
+ const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'http://localhost:28175'
+ const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://mp.sclktx.com/api'
+ const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://mp.sclktx.com/api'
+
+ // 微信小程序端环境区分
+ if (isMpWeixin) {
+ const {
+ miniProgram: { envVersion },
+ } = uni.getAccountInfoSync()
+
+ switch (envVersion) {
+ case 'develop':
+ baseUrl = VITE_SERVER_BASEURL__WEIXIN_DEVELOP || baseUrl
+ break
+ case 'trial':
+ baseUrl = VITE_SERVER_BASEURL__WEIXIN_TRIAL || baseUrl
+ break
+ case 'release':
+ baseUrl = VITE_SERVER_BASEURL__WEIXIN_RELEASE || baseUrl
+ break
+ }
+ }
+
+ return baseUrl
+}
+
+/**
+ * 是否是双token模式
+ */
+export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double'
+
+/**
+ * 首页路径,通过 page.json 里面的 type 为 home 的页面获取,如果没有,则默认是第一个页面
+ * 通常为 /pages/index/index
+ */
+export const HOME_PAGE = `/${(pages as PageMetaDatum[]).find(page => page.type === 'home')?.path || (pages as PageMetaDatum[])[0].path}`
diff --git a/mp-sc-frontend/src/utils/qrcode.ts b/mp-sc-frontend/src/utils/qrcode.ts
new file mode 100644
index 0000000..9b25e81
--- /dev/null
+++ b/mp-sc-frontend/src/utils/qrcode.ts
@@ -0,0 +1,51 @@
+/**
+ * 访客预约二维码工具函数
+ * 二维码内容:{ id, timestamp, sign }
+ * sign = md5(id + "lktx" + timestamp)
+ */
+
+import md5 from '@/lib/md5'
+
+const SALT = 'lktx'
+
+/**
+ * 生成二维码数据
+ */
+export function generateQRData(id: number): { id: number; timestamp: number; sign: string } {
+ const timestamp = Date.now()
+ const sign = md5(id + SALT + timestamp)
+ return { id, timestamp, sign }
+}
+
+/**
+ * 验证二维码数据
+ * @returns { valid: boolean, expired: boolean, timeDiff: number }
+ */
+export function verifyQRData(data: { id: number; timestamp: number; sign: string }): {
+ valid: boolean
+ expired: boolean
+ timeDiff: number
+} {
+ const now = Date.now()
+ const timeDiff = now - data.timestamp
+ const expectedSign = md5(data.id + SALT + data.timestamp)
+
+ if (data.sign !== expectedSign) {
+ return { valid: false, expired: false, timeDiff }
+ }
+
+ return {
+ valid: true,
+ expired: timeDiff > 10000, // 10秒过期
+ timeDiff,
+ }
+}
+
+/**
+ * 格式化二维码生成时间
+ */
+export function formatQRTime(timestamp: number): string {
+ const d = new Date(timestamp)
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
diff --git a/mp-sc-frontend/src/utils/systemInfo.ts b/mp-sc-frontend/src/utils/systemInfo.ts
new file mode 100644
index 0000000..a60f82e
--- /dev/null
+++ b/mp-sc-frontend/src/utils/systemInfo.ts
@@ -0,0 +1,38 @@
+/* eslint-disable import/no-mutable-exports */
+// 获取屏幕边界到安全区域距离
+let systemInfo
+let safeAreaInsets
+
+// #ifdef MP-WEIXIN
+// 微信小程序使用新的API
+systemInfo = uni.getWindowInfo()
+safeAreaInsets = systemInfo.safeArea
+ ? {
+ top: systemInfo.safeArea.top,
+ right: systemInfo.windowWidth - systemInfo.safeArea.right,
+ bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
+ left: systemInfo.safeArea.left,
+ }
+ : null
+// #endif
+
+// #ifndef MP-WEIXIN
+// 其他平台继续使用uni API
+systemInfo = uni.getSystemInfoSync()
+safeAreaInsets = systemInfo.safeAreaInsets
+// #endif
+
+console.log('systemInfo', systemInfo)
+// 微信里面打印
+// pixelRatio: 3
+// safeArea: {top: 47, left: 0, right: 390, bottom: 810, width: 390, …}
+// safeAreaInsets: {top: 47, left: 0, right: 0, bottom: 34}
+// screenHeight: 844
+// screenTop: 91
+// screenWidth: 390
+// statusBarHeight: 47
+// windowBottom: 0
+// windowHeight: 753
+// windowTop: 0
+// windowWidth: 390
+export { safeAreaInsets, systemInfo }
diff --git a/mp-sc-frontend/src/utils/toLoginPage.ts b/mp-sc-frontend/src/utils/toLoginPage.ts
new file mode 100644
index 0000000..6f59f33
--- /dev/null
+++ b/mp-sc-frontend/src/utils/toLoginPage.ts
@@ -0,0 +1,27 @@
+import { getLastPage } from '@/utils'
+import { debounce } from '@/utils/debounce'
+
+interface ToLoginPageOptions {
+ mode?: 'navigateTo' | 'reLaunch'
+ queryString?: string
+}
+
+const LOGIN_PAGE = '/pages/auth/login'
+
+export const toLoginPage = debounce((options: ToLoginPageOptions = {}) => {
+ const { mode = 'navigateTo', queryString = '' } = options
+
+ const url = `${LOGIN_PAGE}${queryString}`
+
+ const currentPage = getLastPage()
+ const currentPath = `/${currentPage.route}`
+ if (currentPath === LOGIN_PAGE) {
+ return
+ }
+
+ if (mode === 'navigateTo') {
+ uni.navigateTo({ url })
+ } else {
+ uni.reLaunch({ url })
+ }
+}, 500)
diff --git a/mp-sc-frontend/src/utils/updateManager.wx.ts b/mp-sc-frontend/src/utils/updateManager.wx.ts
new file mode 100644
index 0000000..20b8b50
--- /dev/null
+++ b/mp-sc-frontend/src/utils/updateManager.wx.ts
@@ -0,0 +1,29 @@
+export default () => {
+ if (!wx.canIUse('getUpdateManager')) {
+ return
+ }
+
+ const updateManager = wx.getUpdateManager()
+
+ updateManager.onCheckForUpdate((res) => {
+ // 请求完新版本信息的回调
+ console.log('版本信息', res)
+ })
+
+ updateManager.onUpdateReady(() => {
+ wx.showModal({
+ title: '更新提示',
+ content: '新版本已经准备好,是否重启应用?',
+ success(res) {
+ if (res.confirm) {
+ // 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
+ updateManager.applyUpdate()
+ }
+ },
+ })
+ })
+
+ updateManager.onUpdateFailed(() => {
+ // 新版本下载失败
+ })
+}
diff --git a/mp-sc-frontend/src/utils/uploadFile.ts b/mp-sc-frontend/src/utils/uploadFile.ts
new file mode 100644
index 0000000..ac5a99b
--- /dev/null
+++ b/mp-sc-frontend/src/utils/uploadFile.ts
@@ -0,0 +1,325 @@
+/**
+ * 文件上传钩子函数使用示例
+ * @example
+ * const { loading, error, data, progress, run } = useUpload(
+ * uploadUrl,
+ * {},
+ * {
+ * maxSize: 5, // 最大5MB
+ * sourceType: ['album'], // 仅支持从相册选择
+ * onProgress: (p) => console.log(`上传进度:${p}%`),
+ * onSuccess: (res) => console.log('上传成功', res),
+ * onError: (err) => console.error('上传失败', err),
+ * },
+ * )
+ */
+
+/**
+ * 上传文件的URL配置
+ */
+export const API_PREFIX = import.meta.env.VITE_SERVER_HAS_API_PREFIX === 'true' ? '/api/v1' : '/v1' = {
+ /** 用户头像上传地址 */
+ USER_AVATAR: `${import.meta.env.VITE_SERVER_BASEURL}/user/avatar`,
+}
+
+/**
+ * 通用文件上传函数(支持直接传入文件路径)
+ * @param url 上传地址
+ * @param filePath 本地文件路径
+ * @param formData 额外表单数据
+ * @param options 上传选项
+ */
+export function useFileUpload(url: string, filePath: string, formData: Record = {}, options: Omit = {}) {
+ return useUpload(
+ url,
+ formData,
+ {
+ ...options,
+ sourceType: ['album'],
+ sizeType: ['original'],
+ },
+ filePath,
+ )
+}
+
+export interface UploadOptions {
+ /** 最大可选择的图片数量,默认为1 */
+ count?: number
+ /** 所选的图片的尺寸,original-原图,compressed-压缩图 */
+ sizeType?: Array<'original' | 'compressed'>
+ /** 选择图片的来源,album-相册,camera-相机 */
+ sourceType?: Array<'album' | 'camera'>
+ /** 文件大小限制,单位:MB */
+ maxSize?: number //
+ /** 上传进度回调函数 */
+ onProgress?: (progress: number) => void
+ /** 上传成功回调函数 */
+ onSuccess?: (res: Record) => void
+ /** 上传失败回调函数 */
+ onError?: (err: Error | UniApp.GeneralCallbackResult) => void
+ /** 上传完成回调函数(无论成功失败) */
+ onComplete?: () => void
+}
+
+/**
+ * 文件上传钩子函数
+ * @template T 上传成功后返回的数据类型
+ * @param url 上传地址
+ * @param formData 额外的表单数据
+ * @param options 上传选项
+ * @returns 上传状态和控制对象
+ */
+export function useUpload(url: string, formData: Record = {}, options: UploadOptions = {},
+ /** 直接传入文件路径,跳过选择器 */
+ directFilePath?: string) {
+ /** 上传中状态 */
+ const loading = ref(false)
+ /** 上传错误状态 */
+ const error = ref(false)
+ /** 上传成功后的响应数据 */
+ const data = ref()
+ /** 上传进度(0-100) */
+ const progress = ref(0)
+
+ /** 解构上传选项,设置默认值 */
+ const {
+ /** 最大可选择的图片数量 */
+ count = 1,
+ /** 所选的图片的尺寸 */
+ sizeType = ['original', 'compressed'],
+ /** 选择图片的来源 */
+ sourceType = ['album', 'camera'],
+ /** 文件大小限制(MB) */
+ maxSize = 10,
+ /** 进度回调 */
+ onProgress,
+ /** 成功回调 */
+ onSuccess,
+ /** 失败回调 */
+ onError,
+ /** 完成回调 */
+ onComplete,
+ } = options
+
+ /**
+ * 检查文件大小是否超过限制
+ * @param size 文件大小(字节)
+ * @returns 是否通过检查
+ */
+ const checkFileSize = (size: number) => {
+ const sizeInMB = size / 1024 / 1024
+ if (sizeInMB > maxSize) {
+ uni.showToast({
+ title: `文件大小不能超过${maxSize}MB`,
+ icon: 'none',
+ })
+ return false
+ }
+ return true
+ }
+ /**
+ * 触发文件选择和上传
+ * 根据平台使用不同的选择器:
+ * - 微信小程序使用 chooseMedia
+ * - 其他平台使用 chooseImage
+ */
+ const run = () => {
+ if (directFilePath) {
+ // 直接使用传入的文件路径
+ loading.value = true
+ progress.value = 0
+ uploadFile({
+ url,
+ tempFilePath: directFilePath,
+ formData,
+ data,
+ error,
+ loading,
+ progress,
+ onProgress,
+ onSuccess,
+ onError,
+ onComplete,
+ })
+ return
+ }
+
+ // #ifdef MP-WEIXIN
+ // 微信小程序环境下使用 chooseMedia API
+ uni.chooseMedia({
+ count,
+ mediaType: ['image'], // 仅支持图片类型
+ sourceType,
+ success: (res) => {
+ const file = res.tempFiles[0]
+ // 检查文件大小是否符合限制
+ if (!checkFileSize(file.size))
+ return
+
+ // 开始上传
+ loading.value = true
+ progress.value = 0
+ uploadFile({
+ url,
+ tempFilePath: file.tempFilePath,
+ formData,
+ data,
+ error,
+ loading,
+ progress,
+ onProgress,
+ onSuccess,
+ onError,
+ onComplete,
+ })
+ },
+ fail: (err) => {
+ console.error('选择媒体文件失败:', err)
+ error.value = true
+ onError?.(err)
+ },
+ })
+ // #endif
+
+ // #ifndef MP-WEIXIN
+ // 非微信小程序环境下使用 chooseImage API
+ uni.chooseImage({
+ count,
+ sizeType,
+ sourceType,
+ success: (res) => {
+ console.log('选择图片成功:', res)
+
+ // 开始上传
+ loading.value = true
+ progress.value = 0
+ uploadFile({
+ url,
+ tempFilePath: res.tempFilePaths[0],
+ formData,
+ data,
+ error,
+ loading,
+ progress,
+ onProgress,
+ onSuccess,
+ onError,
+ onComplete,
+ })
+ },
+ fail: (err) => {
+ console.error('选择图片失败:', err)
+ error.value = true
+ onError?.(err)
+ },
+ })
+ // #endif
+ }
+
+ return { loading, error, data, progress, run }
+}
+
+/**
+ * 文件上传选项接口
+ * @template T 上传成功后返回的数据类型
+ */
+interface UploadFileOptions {
+ /** 上传地址 */
+ url: string
+ /** 临时文件路径 */
+ tempFilePath: string
+ /** 额外的表单数据 */
+ formData: Record
+ /** 上传成功后的响应数据 */
+ data: Ref
+ /** 上传错误状态 */
+ error: Ref
+ /** 上传中状态 */
+ loading: Ref
+ /** 上传进度(0-100) */
+ progress: Ref
+ /** 上传进度回调 */
+ onProgress?: (progress: number) => void
+ /** 上传成功回调 */
+ onSuccess?: (res: Record) => void
+ /** 上传失败回调 */
+ onError?: (err: Error | UniApp.GeneralCallbackResult) => void
+ /** 上传完成回调 */
+ onComplete?: () => void
+}
+
+/**
+ * 执行文件上传
+ * @template T 上传成功后返回的数据类型
+ * @param options 上传选项
+ */
+function uploadFile({
+ url,
+ tempFilePath,
+ formData,
+ data,
+ error,
+ loading,
+ progress,
+ onProgress,
+ onSuccess,
+ onError,
+ onComplete,
+}: UploadFileOptions) {
+ try {
+ // 创建上传任务
+ const uploadTask = uni.uploadFile({
+ url,
+ filePath: tempFilePath,
+ name: 'file', // 文件对应的 key
+ formData,
+ header: {
+ // H5环境下不需要手动设置Content-Type,让浏览器自动处理multipart格式
+ // #ifndef H5
+ 'Content-Type': 'multipart/form-data',
+ // #endif
+ },
+ // 确保文件名称合法
+ success: (uploadFileRes) => {
+ console.log('上传文件成功:', uploadFileRes)
+ try {
+ // 解析响应数据
+ const { data: _data } = JSON.parse(uploadFileRes.data)
+ // 上传成功
+ data.value = _data as T
+ onSuccess?.(_data)
+ }
+ catch (err) {
+ // 响应解析错误
+ console.error('解析上传响应失败:', err)
+ error.value = true
+ onError?.(new Error('上传响应解析失败'))
+ }
+ },
+ fail: (err) => {
+ // 上传请求失败
+ console.error('上传文件失败:', err)
+ error.value = true
+ onError?.(err)
+ },
+ complete: () => {
+ // 无论成功失败都执行
+ loading.value = false
+ onComplete?.()
+ },
+ })
+
+ // 监听上传进度
+ uploadTask.onProgressUpdate((res) => {
+ progress.value = res.progress
+ onProgress?.(res.progress)
+ })
+ }
+ catch (err) {
+ // 创建上传任务失败
+ console.error('创建上传任务失败:', err)
+ error.value = true
+ loading.value = false
+ onError?.(new Error('创建上传任务失败'))
+ }
+}
diff --git a/mp-sc-frontend/tsconfig.json b/mp-sc-frontend/tsconfig.json
new file mode 100644
index 0000000..d8b6672
--- /dev/null
+++ b/mp-sc-frontend/tsconfig.json
@@ -0,0 +1,58 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "lib": [
+ "esnext",
+ "dom"
+ ],
+ "baseUrl": ".",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ],
+ "@img/*": [
+ "./src/static/*"
+ ]
+ },
+ "resolveJsonModule": true,
+ "types": [
+ "@dcloudio/types",
+ "@uni-helper/uni-types",
+ "@uni-helper/vite-plugin-uni-pages",
+ "miniprogram-api-typings",
+ "z-paging/types",
+ "vitest/globals",
+ "./src/types/async-component.d.ts",
+ "./src/types/async-import.d.ts",
+ "./src/typings.d.ts",
+ "@wot-ui/ui/global"
+ ],
+ "allowJs": true,
+ "noImplicitThis": true,
+ "outDir": "dist",
+ "sourceMap": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true
+ },
+ "vueCompilerOptions": {
+ "plugins": [
+ "@uni-helper/uni-types/volar-plugin"
+ ]
+ },
+ "include": [
+ "package.json",
+ "src/**/*.ts",
+ "src/**/*.js",
+ "src/**/*.d.ts",
+ "src/**/*.tsx",
+ "src/**/*.jsx",
+ "src/**/*.vue",
+ "src/**/*.json"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}
diff --git a/mp-sc-frontend/uno.config.ts b/mp-sc-frontend/uno.config.ts
new file mode 100644
index 0000000..c81c7cd
--- /dev/null
+++ b/mp-sc-frontend/uno.config.ts
@@ -0,0 +1,137 @@
+import type { Preset } from 'unocss'
+import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders'
+
+// https://www.npmjs.com/package/@uni-helper/unocss-preset-uni
+import { presetUni } from '@uni-helper/unocss-preset-uni'
+// @see https://unocss.dev/presets/legacy-compat
+import { presetLegacyCompat } from '@unocss/preset-legacy-compat'
+import {
+ defineConfig,
+ presetIcons,
+ transformerDirectives,
+ transformerVariantGroup,
+} from 'unocss'
+
+export default defineConfig({
+ presets: [
+ presetUni({
+ attributify: false,
+ }),
+ presetIcons({
+ scale: 1.2,
+ warn: true,
+ extraProperties: {
+ 'display': 'inline-block',
+ 'vertical-align': 'middle',
+ },
+ collections: {
+ // 注册本地 SVG 图标集合, 从本地文件系统加载图标
+ // 在 './src/static/my-icons' 目录下的所有 svg 文件将被注册为图标,
+ // my-icons 是图标集合名称,使用 `i-my-icons-图标名` 调用
+ 'my-icons': FileSystemIconLoader(
+ './src/static/my-icons',
+ // 可选的,你可以提供一个 transform 回调来更改每个图标
+ (svg) => {
+ let svgStr = svg
+
+ // 如果 SVG 文件未定义 `fill` 属性,则默认填充 `currentColor`, 这样图标颜色会继承文本颜色,方便在不同场景下适配
+ svgStr = svgStr.includes('fill="')
+ ? svgStr
+ : svgStr.replace(/^