初次提交

This commit is contained in:
szz
2025-07-26 15:35:53 +08:00
commit 6da8ea34cb
64 changed files with 15380 additions and 0 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
# 开发环境配置
VITE_API_BASE_URL=/api
VITE_UPLOAD_BASE_URL=http://localhost:3000/api/upload

3
.env.production Normal file
View File

@@ -0,0 +1,3 @@
# 生产环境配置
VITE_API_BASE_URL=http://114.55.111.44:3000/api
VITE_UPLOAD_BASE_URL=http://114.55.111.44:3000/api/upload

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/node_modules

1
dist/assets/About-BgjZeGWd.css vendored Normal file
View File

@@ -0,0 +1 @@
.about-page[data-v-2c0b820f]{min-height:100vh;background-color:#f5f5f5}.navbar[data-v-2c0b820f]{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:56px;background:#fff;border-bottom:1px solid #eee;position:sticky;top:0;z-index:100}.nav-left[data-v-2c0b820f],.nav-right[data-v-2c0b820f]{flex:1}.back-btn[data-v-2c0b820f]{color:#409eff;font-size:14px}.nav-title[data-v-2c0b820f]{margin:0;font-size:18px;font-weight:500;color:#333}.about-content[data-v-2c0b820f]{padding:20px 16px;max-width:800px;margin:0 auto}.intro-section[data-v-2c0b820f]{background:#fff;border-radius:12px;padding:40px 30px;margin-bottom:24px;text-align:center;box-shadow:0 2px 8px #0000001a}.intro-header[data-v-2c0b820f]{margin-bottom:30px}.logo[data-v-2c0b820f]{color:#409eff;margin-bottom:16px}.intro-header h2[data-v-2c0b820f]{margin:0 0 8px;font-size:28px;color:#333;font-weight:600}.tagline[data-v-2c0b820f]{margin:0;font-size:16px;color:#666}.intro-content p[data-v-2c0b820f]{font-size:16px;line-height:1.8;color:#555;text-align:left;margin:0}.features-section[data-v-2c0b820f],.tech-section[data-v-2c0b820f],.contact-section[data-v-2c0b820f]{background:#fff;border-radius:12px;padding:30px;margin-bottom:24px;box-shadow:0 2px 8px #0000001a}.features-section h3[data-v-2c0b820f],.tech-section h3[data-v-2c0b820f],.contact-section h3[data-v-2c0b820f]{margin:0 0 24px;font-size:20px;color:#333;font-weight:600}.features-grid[data-v-2c0b820f]{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:24px}.feature-item[data-v-2c0b820f]{text-align:center;padding:20px;border-radius:8px;background:#f8f9fa;transition:all .3s}.feature-item[data-v-2c0b820f]:hover{transform:translateY(-4px);box-shadow:0 8px 16px #0000001a}.feature-icon[data-v-2c0b820f]{color:#409eff;font-size:32px;margin-bottom:12px}.feature-item h4[data-v-2c0b820f]{margin:0 0 8px;font-size:16px;color:#333;font-weight:500}.feature-item p[data-v-2c0b820f]{margin:0;font-size:14px;color:#666;line-height:1.6}.tech-grid[data-v-2c0b820f]{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:24px}.tech-category h4[data-v-2c0b820f]{margin:0 0 16px;font-size:16px;color:#333;font-weight:500;padding-bottom:8px;border-bottom:2px solid #409eff}.tech-category ul[data-v-2c0b820f]{margin:0;padding:0;list-style:none}.tech-category li[data-v-2c0b820f]{color:#555;font-size:14px;position:relative;padding:8px 0 8px 16px}.tech-category li[data-v-2c0b820f]:before{content:"•";color:#409eff;position:absolute;left:0;font-weight:700}.contact-info[data-v-2c0b820f]{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:24px}.contact-item[data-v-2c0b820f]{display:flex;align-items:center;gap:12px;padding:16px;background:#f8f9fa;border-radius:8px}.contact-item .el-icon[data-v-2c0b820f]{color:#409eff;font-size:24px}.contact-item h4[data-v-2c0b820f]{margin:0 0 4px;font-size:14px;color:#333;font-weight:500}.contact-item p[data-v-2c0b820f]{margin:0;font-size:14px;color:#666}.version-section[data-v-2c0b820f]{background:#fff;border-radius:12px;padding:20px 30px;margin-bottom:24px;box-shadow:0 2px 8px #0000001a}.version-info p[data-v-2c0b820f]{margin:8px 0;font-size:14px;color:#666}.about-footer[data-v-2c0b820f]{text-align:center;padding:20px;color:#999;font-size:14px}@media (max-width: 768px){.about-content[data-v-2c0b820f]{padding:15px 10px}.intro-section[data-v-2c0b820f],.features-section[data-v-2c0b820f],.tech-section[data-v-2c0b820f],.contact-section[data-v-2c0b820f]{padding:20px 15px}.intro-header h2[data-v-2c0b820f]{font-size:24px}.features-grid[data-v-2c0b820f],.tech-grid[data-v-2c0b820f],.contact-info[data-v-2c0b820f]{grid-template-columns:1fr}}@media (max-width: 480px){.intro-section[data-v-2c0b820f],.features-section[data-v-2c0b820f],.tech-section[data-v-2c0b820f],.contact-section[data-v-2c0b820f],.version-section[data-v-2c0b820f]{padding:15px}.intro-header h2[data-v-2c0b820f]{font-size:20px}.features-section h3[data-v-2c0b820f],.tech-section h3[data-v-2c0b820f],.contact-section h3[data-v-2c0b820f]{font-size:18px}}

1
dist/assets/About-CcTakbms.js vendored Normal file
View File

@@ -0,0 +1 @@
import{n as v,N as r,z as c,d as _,O as p,u as b,q as m,P as g,Q as x}from"./elementPlus-DFx51bSH.js";import{_ as V}from"./index-BlP3rxMf.js";import{r as y,y as A,A as t,Q as l,I as o,al as e,aD as N,M as a,O as I,z as P,u as i}from"./vendor-C3mpOp0n.js";const k={class:"about-page"},C={class:"navbar"},E={class:"nav-left"},S={class:"about-content"},T={class:"intro-section"},h={class:"intro-header"},z={class:"logo"},B={class:"features-section"},Q={class:"features-grid"},w={class:"feature-item"},D={class:"feature-icon"},M={class:"feature-item"},O={class:"feature-icon"},R={class:"feature-item"},$={class:"feature-icon"},j={class:"feature-item"},q={class:"feature-icon"},J={class:"contact-section"},L={class:"contact-info"},U={class:"contact-item"},W={class:"contact-item"},F={class:"contact-item"},G={class:"version-section"},H={class:"version-info"},K={__name:"About",setup(X){const d=y("2024-01-15");return(u,s)=>{const n=e("el-icon"),f=e("el-button");return P(),A("div",k,[t("nav",C,[t("div",E,[l(f,{type:"text",onClick:s[0]||(s[0]=Y=>u.$router.go(-1)),class:"back-btn"},{default:o(()=>[l(n,null,{default:o(()=>[l(i(v))]),_:1}),s[1]||(s[1]=a(" 返回 "))]),_:1,__:[1]})]),s[2]||(s[2]=t("div",{class:"nav-center"},[t("h1",{class:"nav-title"},"关于我们")],-1)),s[3]||(s[3]=t("div",{class:"nav-right"},null,-1))]),t("div",S,[t("section",T,[t("div",h,[t("div",z,[l(n,{size:"60"},{default:o(()=>[l(i(r))]),_:1})]),s[4]||(s[4]=t("h2",null,"融互通",-1)),s[5]||(s[5]=t("p",{class:"tagline"},"专业的积分兑换与商品管理平台",-1))]),s[6]||(s[6]=t("div",{class:"intro-content"},[t("p",null," 积分商城系统是一个现代化的积分兑换与商品管理平台,致力于为用户提供丰富的商品选择和便捷的积分兑换体验。 我们相信积分的价值在于为用户带来实际的收益和满足感,通过技术的力量让积分兑换变得更加简单高效。 ")],-1))]),t("section",B,[s[15]||(s[15]=t("h3",null,"功能特色",-1)),t("div",Q,[t("div",w,[t("div",D,[l(n,null,{default:o(()=>[l(i(c))]),_:1})]),s[7]||(s[7]=t("h4",null,"丰富商品",-1)),s[8]||(s[8]=t("p",null,"精选优质商品,涵盖生活用品、数码产品、虚拟服务等多个品类",-1))]),t("div",M,[t("div",O,[l(n,null,{default:o(()=>[l(i(_))]),_:1})]),s[9]||(s[9]=t("h4",null,"积分兑换",-1)),s[10]||(s[10]=t("p",null,"灵活的积分兑换机制,让您的积分发挥最大价值,享受购物乐趣",-1))]),t("div",R,[t("div",$,[l(n,null,{default:o(()=>[l(i(p))]),_:1})]),s[11]||(s[11]=t("h4",null,"积分管理",-1)),s[12]||(s[12]=t("p",null,"完整的积分获取和消费记录,让您清楚了解每一分积分的来源和去向",-1))]),t("div",j,[t("div",q,[l(n,null,{default:o(()=>[l(i(b))]),_:1})]),s[13]||(s[13]=t("h4",null,"个人中心",-1)),s[14]||(s[14]=t("p",null,"完善的个人资料管理,记录您的兑换历程和积分成长轨迹",-1))])])]),s[23]||(s[23]=N('<section class="tech-section" data-v-2c0b820f><h3 data-v-2c0b820f>技术栈</h3><div class="tech-grid" data-v-2c0b820f><div class="tech-category" data-v-2c0b820f><h4 data-v-2c0b820f>前端技术</h4><ul data-v-2c0b820f><li data-v-2c0b820f>Vue 3 + Composition API</li><li data-v-2c0b820f>Element Plus UI 组件库</li><li data-v-2c0b820f>Vue Router 路由管理</li><li data-v-2c0b820f>Pinia 状态管理</li><li data-v-2c0b820f>Vite 构建工具</li><li data-v-2c0b820f>响应式设计</li></ul></div><div class="tech-category" data-v-2c0b820f><h4 data-v-2c0b820f>后端技术</h4><ul data-v-2c0b820f><li data-v-2c0b820f>Node.js + Express</li><li data-v-2c0b820f>MySQL 数据库</li><li data-v-2c0b820f>JWT 身份认证</li><li data-v-2c0b820f>RESTful API 设计</li><li data-v-2c0b820f>积分系统管理</li><li data-v-2c0b820f>订单处理系统</li></ul></div></div></section>',1)),t("section",J,[s[19]||(s[19]=t("h3",null,"联系我们",-1)),t("div",L,[t("div",U,[l(n,null,{default:o(()=>[l(i(m))]),_:1}),s[16]||(s[16]=t("div",null,[t("h4",null,"邮箱"),t("p",null,"contact@example.com")],-1))]),t("div",W,[l(n,null,{default:o(()=>[l(i(g))]),_:1}),s[17]||(s[17]=t("div",null,[t("h4",null,"电话"),t("p",null,"400-123-4567")],-1))]),t("div",F,[l(n,null,{default:o(()=>[l(i(x))]),_:1}),s[18]||(s[18]=t("div",null,[t("h4",null,"地址"),t("p",null,"北京市朝阳区科技园区")],-1))])])]),t("section",G,[t("div",H,[s[21]||(s[21]=t("p",null,[t("strong",null,"版本:"),a("v1.0.0")],-1)),t("p",null,[s[20]||(s[20]=t("strong",null,"更新时间:",-1)),a(I(d.value),1)]),s[22]||(s[22]=t("p",null,[t("strong",null,"开发团队:"),a("积分商城系统开发团队")],-1))])])]),s[24]||(s[24]=t("footer",{class:"about-footer"},[t("p",null,"© 2024 积分商城系统. All rights reserved.")],-1))])}}},lt=V(K,[["__scopeId","data-v-2c0b820f"]]);export{lt as default};

1
dist/assets/Captcha-BFJ1zi5J.js vendored Normal file
View File

@@ -0,0 +1 @@
import{r as n,j as S,h as E,al as v,y as _,z as g,A as o,Q as t,a4 as K,I as u,u as y,M as R}from"./vendor-C3mpOp0n.js";import{v as T,w as C,a as r}from"./elementPlus-DFx51bSH.js";import{_ as $,a as w}from"./index-BlP3rxMf.js";const j={class:"captcha-container"},A={class:"captcha-input-group"},Q=["src"],U={key:1,class:"captcha-loading"},q={class:"captcha-refresh-hint"},D={class:"captcha-actions"},F={__name:"Captcha",props:{modelValue:{type:String,default:""},placeholder:{type:String,default:"请输入验证码"},size:{type:String,default:"large"},autoRefresh:{type:Boolean,default:!0}},emits:["update:modelValue","verify","refresh"],setup(i,{expose:x,emit:k}){const d=i,p=k,s=n(d.modelValue),f=n(""),l=n(""),h=n(!1),I=async()=>{try{h.value=!0;const e=await w.get("/captcha/generate");e.data.success?(f.value=e.data.data.image,l.value=e.data.data.captchaId,p("refresh",{captchaId:l.value})):r.error(e.data.message||"获取验证码失败")}catch(e){console.error("获取验证码失败:",e),r.error("获取验证码失败,请检查网络连接")}finally{h.value=!1}},c=async()=>{s.value="",p("update:modelValue",""),await I()},z=e=>{p("update:modelValue",e)},b=async e=>{if(!l.value)return r.error("请先获取验证码"),!1;if(!e||e.trim()==="")return r.error("请输入验证码"),!1;try{const a=await w.post("/captcha/verify",{captchaId:l.value,captchaText:e.trim()});return a.data.success?!0:(r.error(a.data.message||"验证码错误"),await c(),!1)}catch(a){return console.error("验证验证码失败:",a),r.error("验证验证码失败,请重试"),await c(),!1}},B=()=>({captchaId:l.value,captchaText:s.value});return S(()=>d.modelValue,e=>{s.value=e}),E(()=>{d.autoRefresh&&I()}),x({refreshCaptcha:c,verifyCaptcha:b,getCaptchaInfo:B}),(e,a)=>{const M=v("el-input"),m=v("el-icon"),N=v("el-button");return g(),_("div",j,[o("div",A,[t(M,{modelValue:s.value,"onUpdate:modelValue":a[0]||(a[0]=V=>s.value=V),placeholder:i.placeholder,size:i.size,clearable:"",onKeyup:a[1]||(a[1]=K(V=>e.$emit("verify",{captchaId:l.value,captchaText:s.value}),["enter"])),onInput:z},null,8,["modelValue","placeholder","size"]),o("div",{class:"captcha-image-wrapper",onClick:c},[f.value?(g(),_("img",{key:0,src:f.value,alt:"验证码",class:"captcha-image"},null,8,Q)):(g(),_("div",U,[t(m,{class:"is-loading"},{default:u(()=>[t(y(T))]),_:1}),a[2]||(a[2]=o("span",null,"加载中...",-1))])),o("div",q,[t(m,null,{default:u(()=>[t(y(C))]),_:1}),a[3]||(a[3]=o("span",null,"点击刷新",-1))])])]),o("div",D,[t(N,{type:"text",size:"small",onClick:c,loading:h.value},{default:u(()=>[t(m,null,{default:u(()=>[t(y(C))]),_:1}),a[4]||(a[4]=R(" 刷新验证码 "))]),_:1,__:[4]},8,["loading"])])])}}},L=$(F,[["__scopeId","data-v-cf29bfcb"]]);export{L as C};

1
dist/assets/Captcha-BsLfDNOw.css vendored Normal file
View File

@@ -0,0 +1 @@
.captcha-container[data-v-cf29bfcb]{width:100%}.captcha-input-group[data-v-cf29bfcb]{display:flex;gap:10px;align-items:center}.captcha-input-group .el-input[data-v-cf29bfcb]{flex:1}.captcha-image-wrapper[data-v-cf29bfcb]{position:relative;width:120px;height:40px;border:1px solid #dcdfe6;border-radius:4px;cursor:pointer;overflow:hidden;transition:all .3s;background:#f5f7fa}.captcha-image-wrapper[data-v-cf29bfcb]:hover{border-color:#409eff;box-shadow:0 0 0 1px #409eff}.captcha-image[data-v-cf29bfcb]{width:100%;height:100%;object-fit:cover;display:block}.captcha-loading[data-v-cf29bfcb]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#909399;font-size:12px}.captcha-loading .el-icon[data-v-cf29bfcb]{font-size:16px;margin-bottom:2px}.captcha-refresh-hint[data-v-cf29bfcb]{position:absolute;top:0;left:0;right:0;bottom:0;background:#000000b3;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:0;transition:opacity .3s;font-size:12px}.captcha-image-wrapper:hover .captcha-refresh-hint[data-v-cf29bfcb]{opacity:1}.captcha-refresh-hint .el-icon[data-v-cf29bfcb]{font-size:16px;margin-bottom:2px}.captcha-actions[data-v-cf29bfcb]{margin-top:8px;text-align:right}.captcha-actions .el-button[data-v-cf29bfcb]{padding:0;font-size:12px;color:#909399}.captcha-actions .el-button[data-v-cf29bfcb]:hover{color:#409eff}.captcha-actions .el-icon[data-v-cf29bfcb]{margin-right:4px}@media (max-width: 480px){.captcha-input-group[data-v-cf29bfcb]{flex-direction:column;gap:8px}.captcha-image-wrapper[data-v-cf29bfcb]{width:100%;max-width:200px;height:50px}}

1
dist/assets/Home-Bj4aCm4S.js vendored Normal file
View File

@@ -0,0 +1 @@
import{r as C,h as L,y as m,Q as t,I as o,al as n,aA as E,z as c,A as s,M as a,H as v,u as _,O as p,P as g,K as F,a6 as K,L as O}from"./vendor-C3mpOp0n.js";import{_ as P,u as Q}from"./index-BlP3rxMf.js";import{f as R}from"./elementPlus-DFx51bSH.js";const T={class:"home"},j={class:"header-content"},q={class:"nav-menu"},G={class:"user-actions"},J={class:"user-info"},W={class:"user-avatar"},X={class:"username"},Y={key:0,class:"stats-section"},Z={class:"container"},$={class:"stat-card"},ee={class:"stat-icon"},se={class:"stat-content"},oe={class:"stat-value"},te={class:"stat-label"},ne={__name:"Home",setup(ae){const l=E(),i=Q(),h=C("home"),k=C([{key:"users",label:"用户数量",value:0,icon:"User"},{key:"orders",label:"订单总数",value:0,icon:"Document"},{key:"products",label:"商品数量",value:0,icon:"Star"},{key:"transfers",label:"转账记录",value:0,icon:"Clock"}]),S=r=>{switch(h.value=r,r){case"home":l.push("/transfers");break;case"shop":l.push("/shop");break;case"about":l.push("/about");break}},A=r=>{switch(r){case"profile":l.push("/profile");break;case"orders":l.push("/orders");break;case"points-history":l.push("/points-history");break;case"transfers":l.push("/transfers");break;case"logout":i.logout();break}},H=async()=>{try{k.value=[{key:"users",label:"用户数量",value:156,icon:"User"},{key:"orders",label:"订单总数",value:89,icon:"Document"},{key:"products",label:"商品数量",value:45,icon:"Star"},{key:"transfers",label:"转账记录",value:23,icon:"Clock"}]}catch(r){console.error("获取统计数据失败:",r)}};return L(()=>{i.isAuthenticated&&H()}),(r,e)=>{const f=n("el-menu-item"),U=n("el-menu"),b=n("el-icon"),u=n("el-dropdown-item"),B=n("el-dropdown-menu"),D=n("el-dropdown"),N=n("el-header"),V=n("el-col"),z=n("el-row"),I=n("el-main"),M=n("el-footer");return c(),m("div",T,[t(N,{class:"header"},{default:o(()=>[s("div",j,[e[8]||(e[8]=s("div",{class:"logo"},[s("h2",null,"前端H5系统")],-1)),s("div",q,[t(U,{mode:"horizontal","default-active":h.value,class:"nav-menu-items",onSelect:S},{default:o(()=>[t(f,{index:"home"},{default:o(()=>e[0]||(e[0]=[a("首页")])),_:1,__:[0]}),t(f,{index:"shop"},{default:o(()=>e[1]||(e[1]=[a("积分商城")])),_:1,__:[1]}),t(f,{index:"about"},{default:o(()=>e[2]||(e[2]=[a("关于")])),_:1,__:[2]})]),_:1},8,["default-active"])]),s("div",G,[_(i).isAuthenticated?(c(),v(D,{key:0,onCommand:A},{dropdown:o(()=>[t(B,null,{default:o(()=>[t(u,{command:"profile"},{default:o(()=>e[3]||(e[3]=[a("个人中心")])),_:1,__:[3]}),t(u,{command:"orders"},{default:o(()=>e[4]||(e[4]=[a("我的订单")])),_:1,__:[4]}),t(u,{command:"points-history"},{default:o(()=>e[5]||(e[5]=[a("积分记录")])),_:1,__:[5]}),t(u,{command:"transfers"},{default:o(()=>e[6]||(e[6]=[a("转账管理")])),_:1,__:[6]}),t(u,{divided:"",command:"logout"},{default:o(()=>e[7]||(e[7]=[a("退出登录")])),_:1,__:[7]})]),_:1})]),default:o(()=>{var d,y,w,x;return[s("span",J,[s("div",W,p((w=(y=(d=_(i).user)==null?void 0:d.username)==null?void 0:y.charAt(0))==null?void 0:w.toUpperCase()),1),s("span",X,p((x=_(i).user)==null?void 0:x.username),1),t(b,null,{default:o(()=>[t(_(R))]),_:1})])]}),_:1})):(c(),m(g,{key:1},[],64))])])]),_:1}),t(I,{class:"main-content"},{default:o(()=>[e[10]||(e[10]=s("div",{class:"main-section"},[s("div",{class:"container"},[s("div",{class:"welcome-content"},[s("h2",{class:"welcome-title"},"欢迎使用前端H5系统"),s("p",{class:"welcome-description"},"您的智能管理助手")])])],-1)),_(i).isAuthenticated?(c(),m("div",Y,[s("div",Z,[e[9]||(e[9]=s("h2",{class:"section-title"},"系统概览",-1)),t(z,{gutter:20},{default:o(()=>[(c(!0),m(g,null,K(k.value,d=>(c(),v(V,{xs:12,sm:6,key:d.key},{default:o(()=>[s("div",$,[s("div",ee,[t(b,{size:32},{default:o(()=>[(c(),v(O(d.icon)))]),_:2},1024)]),s("div",se,[s("div",oe,p(d.value),1),s("div",te,p(d.label),1)])])]),_:2},1024))),128))]),_:1})])])):F("",!0)]),_:1,__:[10]}),t(M,{class:"footer"},{default:o(()=>e[11]||(e[11]=[s("div",{class:"container"},[s("p",null,"© 2024 前端H5系统. All rights reserved.")],-1)])),_:1,__:[11]})])}}},ce=P(ne,[["__scopeId","data-v-9eed1601"]]);export{ce as default};

1
dist/assets/Home-DK4JVWxA.css vendored Normal file
View File

@@ -0,0 +1 @@
.home[data-v-9eed1601]{min-height:100vh;display:flex;flex-direction:column}.header[data-v-9eed1601]{background:linear-gradient(135deg,#409eff,#66b1ff);box-shadow:0 2px 12px #0000001a}.header-content[data-v-9eed1601]{display:flex;align-items:center;justify-content:space-between;max-width:1200px;margin:0 auto;padding:0 20px}.logo h2[data-v-9eed1601]{color:#fff;margin:0}.nav-menu-items[data-v-9eed1601]{background:transparent;border:none}.nav-menu-items .el-menu-item[data-v-9eed1601]{color:#fff;border:none}.nav-menu-items .el-menu-item[data-v-9eed1601]:hover,.nav-menu-items .el-menu-item.is-active[data-v-9eed1601]{background-color:#ffffff1a;color:#fff}.user-actions[data-v-9eed1601]{display:flex;align-items:center;gap:10px}.user-info[data-v-9eed1601]{display:flex;align-items:center;gap:8px;color:#fff;cursor:pointer}.user-avatar[data-v-9eed1601]{width:32px;height:32px;border-radius:50%;background-color:#fff3;display:flex;align-items:center;justify-content:center;font-weight:600}.main-content[data-v-9eed1601]{flex:1;padding:0 0 80px}.container[data-v-9eed1601]{max-width:1200px;margin:0 auto;padding:0 20px}.main-section[data-v-9eed1601],.stats-section[data-v-9eed1601]{padding:60px 0}.main-section[data-v-9eed1601]{background-color:#f8f9fa;min-height:50vh}.section-title[data-v-9eed1601]{text-align:center;font-size:32px;margin-bottom:40px;color:#303133}.section-header[data-v-9eed1601]{display:flex;justify-content:space-between;align-items:center;margin-bottom:40px}.welcome-content[data-v-9eed1601]{text-align:center;max-width:600px;margin:0 auto;padding:40px 20px}.welcome-title[data-v-9eed1601]{font-size:32px;margin-bottom:16px;color:#303133;font-weight:600}.welcome-description[data-v-9eed1601]{font-size:16px;color:#606266;line-height:1.6}.stat-card[data-v-9eed1601]{background:#fff;border-radius:8px;padding:20px;box-shadow:0 2px 12px #0000001a;display:flex;align-items:center;gap:15px;margin-bottom:20px}.stat-icon[data-v-9eed1601]{color:#409eff}.stat-value[data-v-9eed1601]{font-size:24px;font-weight:600;color:#303133}.stat-label[data-v-9eed1601]{font-size:14px;color:#909399}.footer[data-v-9eed1601]{background-color:#303133;color:#fff;text-align:center}.footer .container[data-v-9eed1601]{padding:20px}@media (max-width: 768px){.header-content[data-v-9eed1601]{flex-direction:column;gap:15px;padding:15px 20px}.nav-menu[data-v-9eed1601]{order:3;width:100%}.user-actions[data-v-9eed1601]{order:2}.section-title[data-v-9eed1601]{font-size:24px}.section-header[data-v-9eed1601]{flex-direction:column;gap:20px;text-align:center}.welcome-title[data-v-9eed1601]{font-size:24px}.main-content[data-v-9eed1601]{padding-bottom:80px}}

1
dist/assets/Login-CuS3HgBi.js vendored Normal file
View File

@@ -0,0 +1 @@
import{r as w,X as I,h as R,aB as U,aA as B,y as M,A as s,Q as o,I as t,V as S,al as u,M as n,z as F,u as f,a4 as L,O as T}from"./vendor-C3mpOp0n.js";import{_ as $,u as A}from"./index-BlP3rxMf.js";import{a as E,u as N,p as P,l as D}from"./elementPlus-DFx51bSH.js";import{C as K}from"./Captcha-BFJ1zi5J.js";const H={class:"login-page"},O={class:"login-container"},Q={class:"login-card"},X={class:"form-options"},j={class:"login-footer"},G={class:"quick-login"},J={class:"demo-accounts"},W={__name:"Login",setup(Y){const h=B(),b=U(),p=A(),g=w(),d=w(),a=I({username:"",password:"",captcha:""}),_=w(!1),x={username:[{required:!0,message:"请输入用户名或邮箱",trigger:"blur"},{min:3,message:"用户名至少3个字符",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"},{min:6,message:"密码至少6个字符",trigger:"blur"}],captcha:[{required:!0,message:"请输入验证码",trigger:"blur"},{min:4,max:4,message:"验证码为4位字符",trigger:"blur"}]},v=async()=>{if(!(!g.value||!d.value))try{if(!await g.value.validate())return;if(!await d.value.verifyCaptcha(a.captcha)){a.captcha="";return}const c=d.value.getCaptchaInfo(),i={username:a.username,password:a.password,captchaId:c.captchaId,captchaText:c.captchaText};if((await p.login(i)).success){const m=b.query.redirect||"/transfers";h.push(m)}}catch(l){console.error("登录失败:",l),d.value&&await d.value.refreshCaptcha(),a.captcha=""}},V=async l=>{l==="admin"?(a.username="admin",a.password="admin123"):(a.username="user",a.password="user123"),a.captcha="",E.info("请输入验证码后登录")},C=()=>{D.alert("请联系管理员重置密码,或使用演示账号进行体验。","忘记密码",{confirmButtonText:"确定",type:"info"})};return R(()=>{if(p.isAuthenticated){const e=b.query.redirect||"/transfers";h.push(e)}const l=localStorage.getItem("rememberedUsername");l&&(a.username=l,_.value=!0)}),(l,e)=>{const c=u("el-input"),i=u("el-form-item"),k=u("el-checkbox"),m=u("el-link"),y=u("el-button"),q=u("el-form"),z=u("el-divider");return F(),M("div",H,[s("div",O,[s("div",Q,[e[14]||(e[14]=s("div",{class:"login-header"},[s("h2",null,"用户登录"),s("p",null,"欢迎回到前端H5系统")],-1)),o(q,{ref_key:"loginFormRef",ref:g,model:a,rules:x,class:"login-form",onSubmit:S(v,["prevent"])},{default:t(()=>[o(i,{prop:"username"},{default:t(()=>[o(c,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"请输入用户名或邮箱",size:"large","prefix-icon":f(N),clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),o(i,{prop:"password"},{default:t(()=>[o(c,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password",placeholder:"请输入密码",size:"large","prefix-icon":f(P),"show-password":"",clearable:"",onKeyup:L(v,["enter"])},null,8,["modelValue","prefix-icon"])]),_:1}),o(i,{prop:"captcha"},{default:t(()=>[o(K,{ref_key:"captchaRef",ref:d,modelValue:a.captcha,"onUpdate:modelValue":e[2]||(e[2]=r=>a.captcha=r),placeholder:"请输入验证码",size:"large"},null,8,["modelValue"])]),_:1}),o(i,null,{default:t(()=>[s("div",X,[o(k,{modelValue:_.value,"onUpdate:modelValue":e[3]||(e[3]=r=>_.value=r)},{default:t(()=>e[7]||(e[7]=[n("记住我")])),_:1,__:[7]},8,["modelValue"]),o(m,{type:"primary",onClick:C},{default:t(()=>e[8]||(e[8]=[n(" 忘记密码? ")])),_:1,__:[8]})])]),_:1}),o(i,null,{default:t(()=>[o(y,{type:"primary",size:"large",class:"login-button",loading:f(p).loading,onClick:v},{default:t(()=>[n(T(f(p).loading?"登录中...":"登录"),1)]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),s("div",j,[s("p",null,[e[10]||(e[10]=n(" 还没有账号? ")),o(m,{type:"primary",onClick:e[4]||(e[4]=r=>l.$router.push("/register"))},{default:t(()=>e[9]||(e[9]=[n(" 立即注册 ")])),_:1,__:[9]})])]),s("div",G,[o(z,null,{default:t(()=>e[11]||(e[11]=[n("快速登录")])),_:1,__:[11]}),s("div",J,[o(y,{type:"info",plain:"",size:"small",onClick:e[5]||(e[5]=r=>V("admin"))},{default:t(()=>e[12]||(e[12]=[n(" 管理员账号 ")])),_:1,__:[12]}),o(y,{type:"success",plain:"",size:"small",onClick:e[6]||(e[6]=r=>V("user"))},{default:t(()=>e[13]||(e[13]=[n(" 普通用户 ")])),_:1,__:[13]})])])])]),e[15]||(e[15]=s("div",{class:"background-decoration"},[s("div",{class:"decoration-circle circle-1"}),s("div",{class:"decoration-circle circle-2"}),s("div",{class:"decoration-circle circle-3"})],-1))])}}},se=$(W,[["__scopeId","data-v-736bd32a"]]);export{se as default};

1
dist/assets/Login-DV1_4Ght.css vendored Normal file
View File

@@ -0,0 +1 @@
.login-page[data-v-736bd32a]{min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}.login-container[data-v-736bd32a]{width:100%;max-width:400px;padding:20px;position:relative;z-index:10}.login-card[data-v-736bd32a]{background:#fffffff2;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:16px;padding:40px 30px;box-shadow:0 20px 40px #0000001a;border:1px solid rgba(255,255,255,.2)}.login-header[data-v-736bd32a]{text-align:center;margin-bottom:30px}.login-header h2[data-v-736bd32a]{color:#303133;margin-bottom:8px;font-weight:600}.login-header p[data-v-736bd32a]{color:#909399;font-size:14px}.login-form[data-v-736bd32a]{margin-bottom:20px}.form-options[data-v-736bd32a]{display:flex;justify-content:space-between;align-items:center;width:100%}.login-button[data-v-736bd32a]{width:100%;height:44px;font-size:16px;font-weight:600}.login-footer[data-v-736bd32a]{text-align:center;margin-bottom:20px}.login-footer p[data-v-736bd32a]{color:#606266;font-size:14px}.quick-login[data-v-736bd32a]{margin-top:20px}.demo-accounts[data-v-736bd32a]{display:flex;gap:10px;justify-content:center;margin-top:15px}.background-decoration[data-v-736bd32a]{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}.decoration-circle[data-v-736bd32a]{position:absolute;border-radius:50%;background:#ffffff1a;animation:float-736bd32a 6s ease-in-out infinite}.circle-1[data-v-736bd32a]{width:200px;height:200px;top:10%;left:10%;animation-delay:0s}.circle-2[data-v-736bd32a]{width:150px;height:150px;top:60%;right:10%;animation-delay:2s}.circle-3[data-v-736bd32a]{width:100px;height:100px;bottom:20%;left:20%;animation-delay:4s}@keyframes float-736bd32a{0%,to{transform:translateY(0) rotate(0)}50%{transform:translateY(-20px) rotate(180deg)}}@media (max-width: 480px){.login-container[data-v-736bd32a]{padding:15px}.login-card[data-v-736bd32a]{padding:30px 20px}.demo-accounts[data-v-736bd32a]{flex-direction:column}.form-options[data-v-736bd32a]{flex-direction:column;gap:10px;align-items:flex-start}}[data-v-736bd32a] .el-input__wrapper,[data-v-736bd32a] .el-button{border-radius:8px}[data-v-736bd32a] .el-divider__text{background-color:#fffffff2;color:#909399}[data-v-736bd32a] .el-input__wrapper:hover,[data-v-736bd32a] .el-input__wrapper.is-focus{box-shadow:0 0 0 1px #409eff inset}.login-button.is-loading[data-v-736bd32a]{pointer-events:none}.login-card[data-v-736bd32a]{animation:slideInUp-736bd32a .6s ease-out}@keyframes slideInUp-736bd32a{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}[data-v-736bd32a] .el-form-item.is-error .el-input__wrapper{box-shadow:0 0 0 1px #f56c6c inset}[data-v-736bd32a] .el-form-item.is-success .el-input__wrapper{box-shadow:0 0 0 1px #67c23a inset}

1
dist/assets/Matching-CC28Z3KN.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Matching-Coe-22gR.css vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/NotFound-BgFHUPQF.css vendored Normal file
View File

@@ -0,0 +1 @@
.not-found-page[data-v-dda1b05b]{min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;padding:20px}.not-found-container[data-v-dda1b05b]{background:#fff;border-radius:16px;padding:40px;max-width:600px;width:100%;text-align:center;box-shadow:0 20px 40px #0000001a}.error-illustration[data-v-dda1b05b]{margin-bottom:30px}.error-code[data-v-dda1b05b]{font-size:120px;font-weight:700;color:#667eea;line-height:1;margin-bottom:10px;text-shadow:2px 2px 4px rgba(0,0,0,.1)}.error-message[data-v-dda1b05b]{font-size:24px;color:#333;font-weight:500;margin-bottom:20px}.error-description[data-v-dda1b05b]{margin-bottom:30px}.error-description p[data-v-dda1b05b]{color:#666;font-size:16px;line-height:1.6;margin:8px 0}.error-actions[data-v-dda1b05b]{display:flex;gap:16px;justify-content:center;margin-bottom:40px}@media (max-width: 768px){.not-found-page[data-v-dda1b05b]{padding:10px}.not-found-container[data-v-dda1b05b]{padding:30px 20px}.error-code[data-v-dda1b05b]{font-size:80px}.error-message[data-v-dda1b05b]{font-size:20px}.error-actions[data-v-dda1b05b]{flex-direction:column;align-items:center}.error-actions .el-button[data-v-dda1b05b]{width:200px}}@media (max-width: 480px){.error-code[data-v-dda1b05b]{font-size:60px}.error-message[data-v-dda1b05b]{font-size:18px}.error-description p[data-v-dda1b05b]{font-size:14px}}

1
dist/assets/NotFound-CC-kjdAg.js vendored Normal file
View File

@@ -0,0 +1 @@
import{y as c,A as s,Q as t,I as e,al as l,aA as p,z as f,M as d,u as i}from"./vendor-C3mpOp0n.js";import{R as m,n as v}from"./elementPlus-DFx51bSH.js";import{_ as g}from"./index-BlP3rxMf.js";const k={class:"not-found-page"},x={class:"not-found-container"},N={class:"error-actions"},b={__name:"NotFound",setup(w){const n=p(),u=()=>{n.push("/transfers")},_=()=>{window.history.length>1?n.go(-1):n.push("/transfers")};return(y,o)=>{const r=l("el-icon"),a=l("el-button");return f(),c("div",k,[s("div",x,[o[2]||(o[2]=s("div",{class:"error-illustration"},[s("div",{class:"error-code"},"404"),s("div",{class:"error-message"},"页面不存在")],-1)),o[3]||(o[3]=s("div",{class:"error-description"},[s("p",null,"抱歉,您访问的页面不存在或已被删除。"),s("p",null,"请检查网址是否正确,或返回首页继续浏览。")],-1)),s("div",N,[t(a,{type:"primary",onClick:u},{default:e(()=>[t(r,null,{default:e(()=>[t(i(m))]),_:1}),o[0]||(o[0]=d(" 返回首页 "))]),_:1,__:[0]}),t(a,{onClick:_},{default:e(()=>[t(r,null,{default:e(()=>[t(i(v))]),_:1}),o[1]||(o[1]=d(" 返回上页 "))]),_:1,__:[1]})])])])}}},h=g(b,[["__scopeId","data-v-dda1b05b"]]);export{h as default};

1
dist/assets/Orders-Dy3nUi2C.css vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Orders-FHO6V64s.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/PointsHistory-2mZ7lX5s.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.points-history-page[data-v-7135f2f7]{min-height:100vh;background-color:#f5f5f5}.navbar[data-v-7135f2f7]{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:56px;background:#fff;border-bottom:1px solid #eee;position:sticky;top:0;z-index:100}.nav-left[data-v-7135f2f7],.nav-right[data-v-7135f2f7]{flex:1}.nav-right[data-v-7135f2f7]{display:flex;justify-content:flex-end}.back-btn[data-v-7135f2f7],.shop-btn[data-v-7135f2f7]{color:#409eff;font-size:14px}.nav-title[data-v-7135f2f7]{margin:0;font-size:18px;font-weight:500;color:#333}.points-overview[data-v-7135f2f7]{padding:16px}.overview-card[data-v-7135f2f7]{background:linear-gradient(135deg,#667eea,#764ba2);border-radius:16px;padding:24px;color:#fff;box-shadow:0 8px 24px #667eea4d}.current-points[data-v-7135f2f7]{display:flex;align-items:center;gap:16px;margin-bottom:24px}.points-icon[data-v-7135f2f7]{width:48px;height:48px;background:#fff3;border-radius:50%;display:flex;align-items:center;justify-content:center}.points-value[data-v-7135f2f7]{font-size:32px;font-weight:700;line-height:1}.points-label[data-v-7135f2f7]{font-size:14px;opacity:.8;margin-top:4px}.points-stats[data-v-7135f2f7]{display:flex;gap:32px}.stat-item[data-v-7135f2f7]{text-align:center}.stat-value[data-v-7135f2f7]{font-size:20px;font-weight:600;line-height:1}.stat-label[data-v-7135f2f7]{font-size:12px;opacity:.8;margin-top:4px}.filter-section[data-v-7135f2f7]{background:#fff;border-bottom:1px solid #eee;padding:16px}.filter-tabs[data-v-7135f2f7]{display:flex;gap:8px;margin-bottom:16px;overflow-x:auto}.tab-item[data-v-7135f2f7]{padding:8px 16px;border-radius:20px;background:#f5f5f5;color:#666;font-size:14px;cursor:pointer;white-space:nowrap;transition:all .3s}.tab-item.active[data-v-7135f2f7]{background:#409eff;color:#fff}.date-filter[data-v-7135f2f7]{display:flex;justify-content:center}.history-content[data-v-7135f2f7]{padding:16px}.empty-state[data-v-7135f2f7]{text-align:center;padding:60px 20px;color:#999}.history-list[data-v-7135f2f7]{display:flex;flex-direction:column;gap:12px}.history-item[data-v-7135f2f7]{display:flex;align-items:flex-start;gap:12px;background:#fff;border-radius:12px;padding:16px;box-shadow:0 2px 8px #0000001a;transition:all .3s}.history-item[data-v-7135f2f7]:hover{transform:translateY(-2px);box-shadow:0 4px 16px #00000026}.item-icon[data-v-7135f2f7]{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}.icon-earn[data-v-7135f2f7]{background:#e8f5e8;color:#52c41a}.icon-spend[data-v-7135f2f7]{background:#fff2e8;color:#fa8c16}.icon-task[data-v-7135f2f7]{background:#f6ffed;color:#52c41a}.icon-exchange[data-v-7135f2f7]{background:#fff0f6;color:#eb2f96}.icon-present[data-v-7135f2f7]{background:#f9f0ff;color:#722ed1}.icon-review[data-v-7135f2f7]{background:#fff7e6;color:#fa8c16}.icon-share[data-v-7135f2f7]{background:#e6fffb;color:#13c2c2}.icon-default[data-v-7135f2f7]{background:#f5f5f5;color:#999}.item-content[data-v-7135f2f7]{flex:1}.item-header[data-v-7135f2f7]{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}.item-title[data-v-7135f2f7]{margin:0;font-size:16px;color:#333;font-weight:500;line-height:1.4}.item-points[data-v-7135f2f7]{font-size:16px;font-weight:600;white-space:nowrap}.points-positive[data-v-7135f2f7]{color:#52c41a}.points-negative[data-v-7135f2f7]{color:#ff4d4f}.item-details[data-v-7135f2f7]{display:flex;flex-direction:column;gap:8px}.item-description[data-v-7135f2f7]{margin:0;font-size:14px;color:#666;line-height:1.4}.item-meta[data-v-7135f2f7]{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#999}.item-date[data-v-7135f2f7]{flex:1}.item-order[data-v-7135f2f7]{color:#409eff}.item-action[data-v-7135f2f7]{display:flex;align-items:center}.load-more[data-v-7135f2f7]{text-align:center;padding:20px}.points-rules[data-v-7135f2f7]{margin:16px;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0000001a}.rules-content[data-v-7135f2f7]{padding:16px}.rule-item[data-v-7135f2f7]{display:flex;align-items:center;gap:8px;padding:8px 0;font-size:14px;color:#666}.rule-item .el-icon[data-v-7135f2f7]{color:#409eff}@media (max-width: 480px){.points-stats[data-v-7135f2f7]{gap:16px}.item-header[data-v-7135f2f7],.item-meta[data-v-7135f2f7]{flex-direction:column;align-items:flex-start;gap:4px}.current-points[data-v-7135f2f7]{flex-direction:column;text-align:center;gap:8px}}

1
dist/assets/ProductDetail-C9NaqNoj.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/Profile-CE52AKg0.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Profile-hPqRnATi.css vendored Normal file
View File

@@ -0,0 +1 @@
@charset "UTF-8";.profile-page[data-v-162b7df4]{min-height:100vh;background-color:#f5f5f5}.navbar[data-v-162b7df4]{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:56px;background:#fff;border-bottom:1px solid #eee;position:sticky;top:0;z-index:100}.nav-left[data-v-162b7df4],.nav-right[data-v-162b7df4]{flex:1}.nav-right[data-v-162b7df4]{text-align:right}.back-btn[data-v-162b7df4]{color:#409eff;font-size:14px}.nav-title[data-v-162b7df4]{margin:0;font-size:18px;font-weight:500;color:#333}.el-dropdown-link[data-v-162b7df4]{cursor:pointer;color:#409eff;font-size:18px}.profile-content[data-v-162b7df4]{padding:20px 16px}.profile-header[data-v-162b7df4]{background:linear-gradient(135deg,#667eea,#764ba2);border-radius:12px;padding:25px;margin-bottom:20px;box-shadow:0 4px 12px #00000026;color:#fff}.avatar-section[data-v-162b7df4]{text-align:center;margin-bottom:20px}.user-avatar[data-v-162b7df4]{margin-bottom:10px}.upload-btn[data-v-162b7df4]{font-size:12px}.user-info[data-v-162b7df4]{text-align:center}.username[data-v-162b7df4]{margin:0 0 5px;font-size:22px;color:#fff;font-weight:600}.user-email[data-v-162b7df4]{margin:0 0 10px;color:#fffc;font-size:14px}.audit-status[data-v-162b7df4]{margin-bottom:20px;display:flex;align-items:center;gap:10px}.audit-tag[data-v-162b7df4]{font-weight:500}.audit-tip[data-v-162b7df4]{font-size:12px;color:#ffffffb3;background:#ffffff1a;padding:2px 8px;border-radius:4px;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.user-stats[data-v-162b7df4]{display:flex;justify-content:center;gap:30px}.stat-item[data-v-162b7df4]{text-align:center}.stat-number[data-v-162b7df4]{display:block;font-size:20px;font-weight:700;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.1)}.stat-label[data-v-162b7df4]{font-size:12px;color:#fffc}.profile-form[data-v-162b7df4],.password-section[data-v-162b7df4]{background:#fff;border-radius:12px;padding:25px;margin-bottom:20px;box-shadow:0 4px 12px #00000014;border:1px solid rgba(0,0,0,.05)}.password-section h3[data-v-162b7df4]{margin:0 0 20px;font-size:16px;color:#333}.form-actions[data-v-162b7df4]{text-align:center;margin-top:30px;display:flex;gap:15px;justify-content:center}.form-actions .el-button[data-v-162b7df4]{min-width:100px;border-radius:8px;font-weight:500}.avatar-uploader[data-v-162b7df4]{text-align:center}.avatar-uploader[data-v-162b7df4] .el-upload{border:1px dashed #d9d9d9;border-radius:6px;cursor:pointer;position:relative;overflow:hidden;transition:.2s;width:178px;height:178px;display:flex;align-items:center;justify-content:center}.avatar-uploader[data-v-162b7df4] .el-upload:hover{border-color:#409eff}.avatar-uploader-icon[data-v-162b7df4]{font-size:28px;color:#8c939d}.avatar-preview[data-v-162b7df4]{width:178px;height:178px;object-fit:cover}.qr-upload-container[data-v-162b7df4]{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.qr-uploader[data-v-162b7df4]{width:120px}.qr-uploader[data-v-162b7df4] .el-upload{border:2px dashed #d9d9d9;border-radius:8px;cursor:pointer;position:relative;overflow:hidden;transition:.3s;width:120px;height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;background-color:#fafafa}.qr-uploader[data-v-162b7df4] .el-upload:hover{border-color:#409eff;background-color:#f0f9ff}.qr-upload-placeholder[data-v-162b7df4]{display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;text-align:center}.qr-upload-icon[data-v-162b7df4]{font-size:24px;color:#8c939d;margin-bottom:8px}.qr-upload-text[data-v-162b7df4]{font-size:12px;color:#8c939d;line-height:1.2}.qr-preview[data-v-162b7df4]{width:120px;height:120px;object-fit:cover;border-radius:6px}.remove-btn[data-v-162b7df4]{align-self:flex-start}@media (max-width: 768px){.profile-content[data-v-162b7df4]{padding:10px}.profile-header[data-v-162b7df4],.profile-form[data-v-162b7df4],.password-section[data-v-162b7df4]{padding:20px;border-radius:8px}.user-stats[data-v-162b7df4]{gap:15px}.stat-number[data-v-162b7df4]{font-size:18px}.username[data-v-162b7df4]{font-size:20px}.qr-uploader[data-v-162b7df4]{width:100px}.qr-uploader[data-v-162b7df4] .el-upload,.qr-preview[data-v-162b7df4]{width:100px;height:100px}.form-actions[data-v-162b7df4]{flex-direction:column;align-items:center}.form-actions .el-button[data-v-162b7df4]{width:100%;max-width:200px}.el-form[data-v-162b7df4] .el-form-item__label{width:80px!important;font-size:14px}}

15
dist/assets/Register-CV6SJMyQ.js vendored Normal file
View File

@@ -0,0 +1,15 @@
import{r as w,X as B,h as M,aB as S,aA as T,y as I,A as o,aD as q,Q as t,I as s,V as k,al as p,M as u,z as F,u as d,O as N}from"./vendor-C3mpOp0n.js";import{_ as $,u as D}from"./index-BlP3rxMf.js";import{u as V,q as H,t as L,p as x,r as Z,o as O,l as y,a as Q}from"./elementPlus-DFx51bSH.js";import{C as X}from"./Captcha-BFJ1zi5J.js";const j={class:"register-page"},G={class:"register-container"},J={class:"register-card"},K={class:"register-footer"},W={class:"features-preview"},Y={class:"features-list"},ee={class:"feature-item"},re={class:"feature-item"},te={class:"feature-item"},ae={__name:"Register",setup(se){const _=T(),b=S(),m=D(),g=w(),f=w(),a=B({username:"",phone:"",registrationCode:"",password:"",confirmPassword:"",captcha:"",agreement:!1}),C={username:[{validator:(n,e,r)=>{e?e.length<3?r(new Error("用户名至少3个字符")):e.length>20?r(new Error("用户名不能超过20个字符")):/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(e)?r():r(new Error("用户名只能包含字母、数字、下划线和中文")):r(new Error("请输入用户名"))},trigger:"blur"}],phone:[{required:!0,message:"请输入手机号",trigger:"blur"},{pattern:/^1[3-9]\d{9}$/,message:"请输入正确的手机号",trigger:"blur"}],registrationCode:[{required:!0,message:"请输入激活码",trigger:"blur"},{min:6,message:"激活码长度不能少于6位",trigger:"blur"}],password:[{validator:(n,e,r)=>{var i;e?e.length<6?r(new Error("密码至少6个字符")):e.length>20?r(new Error("密码不能超过20个字符")):/(?=.*[a-zA-Z])(?=.*\d)/.test(e)?(a.confirmPassword&&((i=g.value)==null||i.validateField("confirmPassword")),r()):r(new Error("密码必须包含字母和数字")):r(new Error("请输入密码"))},trigger:"blur"}],confirmPassword:[{validator:(n,e,r)=>{e?e!==a.password?r(new Error("两次输入的密码不一致")):r():r(new Error("请确认密码"))},trigger:"blur"}],captcha:[{required:!0,message:"请输入验证码",trigger:"blur"},{min:4,max:4,message:"验证码长度为4位",trigger:"blur"}],agreement:[{validator:(n,e,r)=>{e?r():r(new Error("请阅读并同意用户协议和隐私政策"))},trigger:"change"}]},h=async()=>{if(!(!g.value||!f.value))try{if(!await g.value.validate())return;if(!await f.value.verifyCaptcha(a.captcha)){a.captcha="";return}const r=f.value.getCaptchaInfo(),i={username:a.username,phone:a.phone,registrationCode:a.registrationCode,password:a.password,captchaId:r.captchaId,captchaText:r.captchaText};(await m.register(i)).success&&(Q.success("注册成功!请登录"),_.push("/login"))}catch(n){console.error("注册失败:",n),f.value&&await f.value.refreshCaptcha(),a.captcha=""}},E=()=>{y.alert(`<div style="text-align: left; line-height: 1.6;">
<h3>用户协议</h3>
<p>1. 用户应当遵守法律法规,不得发布违法违规内容。</p>
<p>2. 用户对自己发布的内容承担全部责任。</p>
<p>3. 平台有权对违规内容进行删除或限制。</p>
<p>4. 用户应当保护好自己的账号安全。</p>
<p>5. 平台保留修改本协议的权利。</p>
</div>`,"用户协议",{confirmButtonText:"我已了解",dangerouslyUseHTMLString:!0,customClass:"agreement-dialog"})},P=()=>{y.alert(`<div style="text-align: left; line-height: 1.6;">
<h3>隐私政策</h3>
<p>1. 我们重视用户隐私保护。</p>
<p>2. 我们只收集必要的用户信息。</p>
<p>3. 用户信息仅用于提供服务。</p>
<p>4. 我们不会向第三方泄露用户信息。</p>
<p>5. 用户有权查看、修改或删除个人信息。</p>
</div>`,"隐私政策",{confirmButtonText:"我已了解",dangerouslyUseHTMLString:!0,customClass:"privacy-dialog"})};return M(()=>{if(m.isAuthenticated){const n=b.query.redirect||"/";_.push(n)}}),(n,e)=>{const r=p("el-input"),i=p("el-form-item"),c=p("el-link"),U=p("el-checkbox"),z=p("el-button"),R=p("el-form"),A=p("el-divider"),v=p("el-icon");return F(),I("div",j,[o("div",G,[o("div",J,[e[18]||(e[18]=o("div",{class:"register-header"},[o("h2",null,"用户注册"),o("p",null,"创建你的账号开始使用前端H5系统")],-1)),t(R,{ref_key:"registerFormRef",ref:g,model:a,rules:C,class:"register-form",onSubmit:k(h,["prevent"])},{default:s(()=>[t(i,{prop:"username"},{default:s(()=>[t(r,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=l=>a.username=l),placeholder:"请输入用户名",size:"large","prefix-icon":d(V),clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),t(i,{prop:"phone"},{default:s(()=>[t(r,{modelValue:a.phone,"onUpdate:modelValue":e[1]||(e[1]=l=>a.phone=l),placeholder:"请输入手机号",size:"large","prefix-icon":d(H),clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),t(i,{prop:"registrationCode"},{default:s(()=>[t(r,{modelValue:a.registrationCode,"onUpdate:modelValue":e[2]||(e[2]=l=>a.registrationCode=l),placeholder:"请输入激活码",size:"large","prefix-icon":d(L),clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),t(i,{prop:"password"},{default:s(()=>[t(r,{modelValue:a.password,"onUpdate:modelValue":e[3]||(e[3]=l=>a.password=l),type:"password",placeholder:"请输入密码",size:"large","prefix-icon":d(x),"show-password":"",clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),t(i,{prop:"confirmPassword"},{default:s(()=>[t(r,{modelValue:a.confirmPassword,"onUpdate:modelValue":e[4]||(e[4]=l=>a.confirmPassword=l),type:"password",placeholder:"请确认密码",size:"large","prefix-icon":d(x),"show-password":"",clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),t(i,{prop:"captcha"},{default:s(()=>[t(X,{ref_key:"captchaRef",ref:f,modelValue:a.captcha,"onUpdate:modelValue":e[5]||(e[5]=l=>a.captcha=l),placeholder:"请输入验证码",size:"large"},null,8,["modelValue"])]),_:1}),t(i,{prop:"agreement"},{default:s(()=>[t(U,{modelValue:a.agreement,"onUpdate:modelValue":e[6]||(e[6]=l=>a.agreement=l)},{default:s(()=>[e[10]||(e[10]=u(" 我已阅读并同意 ")),t(c,{type:"primary",onClick:E},{default:s(()=>e[8]||(e[8]=[u(" 《用户协议》 ")])),_:1,__:[8]}),e[11]||(e[11]=u(" 和 ")),t(c,{type:"primary",onClick:P},{default:s(()=>e[9]||(e[9]=[u(" 《隐私政策》 ")])),_:1,__:[9]})]),_:1,__:[10,11]},8,["modelValue"])]),_:1}),t(i,null,{default:s(()=>[t(z,{type:"primary",size:"large",class:"register-button",loading:d(m).loading,onClick:h},{default:s(()=>[u(N(d(m).loading?"注册中...":"立即注册"),1)]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),o("div",K,[o("p",null,[e[13]||(e[13]=u(" 已有账号? ")),t(c,{type:"primary",onClick:e[7]||(e[7]=l=>n.$router.push("/login"))},{default:s(()=>e[12]||(e[12]=[u(" 立即登录 ")])),_:1,__:[12]})])]),o("div",W,[t(A,null,{default:s(()=>e[14]||(e[14]=[u("注册后你可以")])),_:1,__:[14]}),o("div",Y,[o("div",ee,[t(v,null,{default:s(()=>[t(d(V))]),_:1}),e[15]||(e[15]=o("span",null,"个性化用户中心",-1))]),o("div",re,[t(v,null,{default:s(()=>[t(d(Z))]),_:1}),e[16]||(e[16]=o("span",null,"积分商城购物",-1))]),o("div",te,[t(v,null,{default:s(()=>[t(d(O))]),_:1}),e[17]||(e[17]=o("span",null,"积分转账功能",-1))])])])])]),e[19]||(e[19]=q('<div class="background-decoration" data-v-fa9bb9c3><div class="decoration-shape shape-1" data-v-fa9bb9c3></div><div class="decoration-shape shape-2" data-v-fa9bb9c3></div><div class="decoration-shape shape-3" data-v-fa9bb9c3></div><div class="decoration-shape shape-4" data-v-fa9bb9c3></div></div>',1))])}}},me=$(ae,[["__scopeId","data-v-fa9bb9c3"]]);export{me as default};

1
dist/assets/Register-Cq2fSWm7.css vendored Normal file
View File

@@ -0,0 +1 @@
@charset "UTF-8";.register-page[data-v-fa9bb9c3]{min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}.register-container[data-v-fa9bb9c3]{width:100%;max-width:450px;padding:20px;position:relative;z-index:10}.register-card[data-v-fa9bb9c3]{background:#fffffff2;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:16px;padding:40px 30px;box-shadow:0 20px 40px #0000001a;border:1px solid rgba(255,255,255,.2)}.register-header[data-v-fa9bb9c3]{text-align:center;margin-bottom:30px}.register-header h2[data-v-fa9bb9c3]{color:#303133;margin-bottom:8px;font-weight:600}.register-header p[data-v-fa9bb9c3]{color:#909399;font-size:14px}.register-form[data-v-fa9bb9c3]{margin-bottom:20px}.register-button[data-v-fa9bb9c3]{width:100%;height:44px;font-size:16px;font-weight:600}.register-footer[data-v-fa9bb9c3]{text-align:center;margin-bottom:20px}.register-footer p[data-v-fa9bb9c3]{color:#606266;font-size:14px}.document-upload-section[data-v-fa9bb9c3],.document-upload-section .el-divider[data-v-fa9bb9c3]{margin:20px 0}.document-upload-section .el-form-item[data-v-fa9bb9c3]{margin-bottom:20px}.document-upload-section .el-form-item .el-form-item__label[data-v-fa9bb9c3]{font-weight:500;color:#606266}.features-preview[data-v-fa9bb9c3]{margin-top:20px}.features-list[data-v-fa9bb9c3]{display:flex;flex-direction:column;gap:12px;margin-top:15px}.feature-item[data-v-fa9bb9c3]{display:flex;align-items:center;gap:8px;color:#606266;font-size:14px}.feature-item .el-icon[data-v-fa9bb9c3]{color:#409eff;font-size:16px}.upload-preview[data-v-fa9bb9c3]{margin-top:10px}.upload-preview img[data-v-fa9bb9c3]{width:100px;height:100px;object-fit:cover;border-radius:8px;border:1px solid #dcdfe6}.upload-demo[data-v-fa9bb9c3]{margin-bottom:10px}.background-decoration[data-v-fa9bb9c3]{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}.decoration-shape[data-v-fa9bb9c3]{position:absolute;background:#ffffff1a;animation:float-fa9bb9c3 8s ease-in-out infinite}.shape-1[data-v-fa9bb9c3]{width:100px;height:100px;border-radius:50%;top:15%;left:15%;animation-delay:0s}.shape-2[data-v-fa9bb9c3]{width:80px;height:80px;border-radius:20px;top:70%;right:20%;animation-delay:2s}.shape-3[data-v-fa9bb9c3]{width:60px;height:60px;border-radius:50%;bottom:25%;left:25%;animation-delay:4s}.shape-4[data-v-fa9bb9c3]{width:120px;height:40px;border-radius:20px;top:40%;right:10%;animation-delay:6s}@keyframes float-fa9bb9c3{0%,to{transform:translateY(0) rotate(0);opacity:.7}50%{transform:translateY(-15px) rotate(180deg);opacity:1}}@media (max-width: 480px){.register-container[data-v-fa9bb9c3]{padding:15px}.register-card[data-v-fa9bb9c3]{padding:30px 20px}.features-list[data-v-fa9bb9c3]{gap:8px}}[data-v-fa9bb9c3] .el-input__wrapper,[data-v-fa9bb9c3] .el-button{border-radius:8px}[data-v-fa9bb9c3] .el-divider__text{background-color:#fffffff2;color:#909399}[data-v-fa9bb9c3] .el-checkbox__label{font-size:14px;color:#606266}[data-v-fa9bb9c3] .el-input__wrapper:hover,[data-v-fa9bb9c3] .el-input__wrapper.is-focus{box-shadow:0 0 0 1px #409eff inset}.register-button.is-loading[data-v-fa9bb9c3]{pointer-events:none}.register-card[data-v-fa9bb9c3]{animation:slideInUp-fa9bb9c3 .6s ease-out}@keyframes slideInUp-fa9bb9c3{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}[data-v-fa9bb9c3] .el-form-item.is-error .el-input__wrapper{box-shadow:0 0 0 1px #f56c6c inset}[data-v-fa9bb9c3] .el-form-item.is-success .el-input__wrapper{box-shadow:0 0 0 1px #67c23a inset}[data-v-fa9bb9c3] .agreement-dialog .el-message-box__content,[data-v-fa9bb9c3] .privacy-dialog .el-message-box__content{max-height:400px;overflow-y:auto}.password-strength[data-v-fa9bb9c3]{margin-top:5px;font-size:12px}.strength-weak[data-v-fa9bb9c3]{color:#f56c6c}.strength-medium[data-v-fa9bb9c3]{color:#e6a23c}.strength-strong[data-v-fa9bb9c3]{color:#67c23a}

1
dist/assets/Shop-BdOVEivk.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Shop-DSyA6-Or.css vendored Normal file
View File

@@ -0,0 +1 @@
.shop-page[data-v-2008b8d0]{min-height:100vh;background-color:#f5f5f5}.navbar[data-v-2008b8d0]{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:56px;background:#fff;border-bottom:1px solid #eee;position:sticky;top:0;z-index:100}.nav-left[data-v-2008b8d0],.nav-right[data-v-2008b8d0]{flex:1}.nav-right[data-v-2008b8d0]{display:flex;justify-content:flex-end}.back-btn[data-v-2008b8d0],.points-btn[data-v-2008b8d0]{color:#409eff;font-size:14px}.nav-title[data-v-2008b8d0]{margin:0;font-size:18px;font-weight:500;color:#333}.search-section[data-v-2008b8d0]{padding:16px;background:#fff;border-bottom:1px solid #eee}.search-input[data-v-2008b8d0]{width:100%}.category-section[data-v-2008b8d0]{background:#fff;padding:16px 0;border-bottom:1px solid #eee}.category-list[data-v-2008b8d0]{display:flex;gap:16px;padding:0 16px;white-space:nowrap}.category-item[data-v-2008b8d0]{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 12px;border-radius:8px;cursor:pointer;transition:all .3s;min-width:60px}.category-item[data-v-2008b8d0]:hover{background:#f0f9ff}.category-item.active[data-v-2008b8d0]{background:#409eff;color:#fff}.category-item span[data-v-2008b8d0]{font-size:12px}.products-section[data-v-2008b8d0]{padding:16px}.section-header[data-v-2008b8d0]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.section-header h3[data-v-2008b8d0]{margin:0;font-size:16px;color:#333}.sort-btn[data-v-2008b8d0]{display:flex;align-items:center;gap:4px;color:#666;font-size:14px;cursor:pointer}.products-grid[data-v-2008b8d0]{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:20px}.product-card[data-v-2008b8d0]{background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0000001a;cursor:pointer;transition:all .3s}.product-card[data-v-2008b8d0]:hover{transform:translateY(-4px);box-shadow:0 8px 16px #00000026}.product-image[data-v-2008b8d0]{position:relative;width:100%;height:120px;overflow:hidden}.product-image img[data-v-2008b8d0]{width:100%;height:100%;object-fit:cover}.discount-badge[data-v-2008b8d0]{position:absolute;top:8px;right:8px;background:#ff4757;color:#fff;padding:2px 6px;border-radius:4px;font-size:12px}.product-info[data-v-2008b8d0]{padding:12px}.product-name[data-v-2008b8d0]{margin:0 0 4px;font-size:14px;font-weight:500;color:#333;line-height:1.4}.product-desc[data-v-2008b8d0]{margin:0 0 8px;font-size:12px;color:#666;line-height:1.4}.product-price[data-v-2008b8d0]{display:flex;align-items:center;gap:8px;margin-bottom:8px}.current-price[data-v-2008b8d0]{display:flex;align-items:center;gap:2px;color:#ff6b35;font-weight:600;font-size:16px}.original-price[data-v-2008b8d0]{color:#999;font-size:12px;text-decoration:line-through}.product-stats[data-v-2008b8d0]{display:flex;justify-content:space-between;font-size:12px;color:#999;margin-bottom:8px}.product-actions[data-v-2008b8d0]{padding:0 12px 12px}.product-actions .el-button[data-v-2008b8d0]{width:100%}.empty-state[data-v-2008b8d0]{text-align:center;padding:60px 20px;color:#999}.load-more[data-v-2008b8d0]{text-align:center;padding:20px}.cart-fab[data-v-2008b8d0]{position:fixed;bottom:80px;right:20px;width:56px;height:56px;background:#409eff;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;cursor:pointer;box-shadow:0 4px 12px #409eff66;z-index:1000}.cart-content[data-v-2008b8d0]{height:100%;display:flex;flex-direction:column}.empty-cart[data-v-2008b8d0]{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#999}.cart-item[data-v-2008b8d0]{display:flex;align-items:center;gap:12px;padding:16px 0;border-bottom:1px solid #eee}.item-image[data-v-2008b8d0]{width:60px;height:60px;border-radius:8px;object-fit:cover}.item-info[data-v-2008b8d0]{flex:1}.item-info h4[data-v-2008b8d0]{margin:0 0 4px;font-size:14px;color:#333}.item-price[data-v-2008b8d0]{display:flex;align-items:center;gap:2px;color:#ff6b35;font-weight:600;margin:0}.item-actions[data-v-2008b8d0]{display:flex;flex-direction:column;gap:8px;align-items:flex-end}.cart-footer[data-v-2008b8d0]{margin-top:auto;padding:20px 0;border-top:1px solid #eee}.total-points[data-v-2008b8d0]{display:flex;align-items:center;gap:4px;font-size:18px;font-weight:600;color:#ff6b35;margin-bottom:16px}@media (max-width: 480px){.products-grid[data-v-2008b8d0]{grid-template-columns:1fr}.product-card[data-v-2008b8d0]{display:flex}.product-image[data-v-2008b8d0]{width:120px;height:120px;flex-shrink:0}.product-info[data-v-2008b8d0]{flex:1}}

1
dist/assets/TaskCenter-BOIbsjh-.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/TaskCenter-_aLIEz6D.css vendored Normal file
View File

@@ -0,0 +1 @@
.task-center[data-v-9f05f885]{min-height:100vh;background:#f5f7fa}.navbar[data-v-9f05f885]{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:56px;background:#fff;box-shadow:0 2px 4px #0000001a}.nav-left[data-v-9f05f885],.nav-right[data-v-9f05f885]{flex:1}.nav-right[data-v-9f05f885]{display:flex;justify-content:flex-end}.back-btn[data-v-9f05f885],.points-btn[data-v-9f05f885]{color:#333;font-size:14px}.points-btn[data-v-9f05f885]{color:#ff6b35;font-weight:700}.nav-title[data-v-9f05f885]{margin:0;font-size:18px;font-weight:500;text-align:center}.task-stats[data-v-9f05f885]{padding:16px}.stats-card[data-v-9f05f885]{background:#fff;border-radius:12px;padding:20px;display:flex;justify-content:space-around;box-shadow:0 2px 8px #0000001a}.stat-item[data-v-9f05f885]{display:flex;flex-direction:column;align-items:center;text-align:center}.stat-icon[data-v-9f05f885]{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;color:#fff;margin-bottom:8px}.stat-value[data-v-9f05f885]{font-size:20px;font-weight:700;color:#333;margin-bottom:4px}.stat-label[data-v-9f05f885]{font-size:12px;color:#666}.task-categories[data-v-9f05f885]{padding:0 16px 16px}.category-tabs[data-v-9f05f885]{display:flex;background:#fff;border-radius:12px;padding:4px;box-shadow:0 2px 8px #0000001a}.category-tab[data-v-9f05f885]{flex:1;display:flex;flex-direction:column;align-items:center;padding:12px 8px;border-radius:8px;cursor:pointer;transition:all .3s;font-size:12px;color:#666}.category-tab.active[data-v-9f05f885]{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}.category-tab .el-icon[data-v-9f05f885]{margin-bottom:4px}.task-list[data-v-9f05f885]{padding:0 16px}.task-item[data-v-9f05f885]{background:#fff;border-radius:12px;padding:16px;margin-bottom:12px;display:flex;align-items:center;box-shadow:0 2px 8px #0000001a;transition:all .3s}.task-item.completed[data-v-9f05f885]{border-left:4px solid #67c23a}.task-item.claimed[data-v-9f05f885]{opacity:.6;border-left:4px solid #909399}.task-icon[data-v-9f05f885]{width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;color:#fff;margin-right:16px}.task-content[data-v-9f05f885]{flex:1}.task-title[data-v-9f05f885]{font-size:16px;font-weight:500;color:#333;margin-bottom:4px}.task-desc[data-v-9f05f885]{font-size:14px;color:#666;margin-bottom:8px}.task-progress[data-v-9f05f885]{display:flex;align-items:center;gap:8px}.task-progress .el-progress[data-v-9f05f885]{flex:1}.progress-text[data-v-9f05f885]{font-size:12px;color:#666;white-space:nowrap}.task-reward[data-v-9f05f885]{display:flex;flex-direction:column;align-items:center;gap:8px}.reward-points[data-v-9f05f885]{font-size:14px;font-weight:700;color:#ff6b35}.empty-state[data-v-9f05f885]{text-align:center;padding:60px 20px;color:#666}.empty-state p[data-v-9f05f885]{margin-top:16px;font-size:14px}

1
dist/assets/Transfers-9iymu-FU.css vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Transfers-D3Wb5NEb.js vendored Normal file

File diff suppressed because one or more lines are too long

37
dist/assets/elementPlus-DFx51bSH.js vendored Normal file

File diff suppressed because one or more lines are too long

8
dist/assets/index-BlP3rxMf.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-BqycflTi.js vendored Normal file
View File

@@ -0,0 +1 @@
const e={development:{baseURL:"http://localhost:3000",uploadURL:"http://114.55.111.44:3000/api/upload"},production:{baseURL:window.location.origin,uploadURL:"http://114.55.111.44:3000/api/upload"}},a="production",{baseURL:r,uploadURL:s}=e[a],c=t=>{if(!t)return"";if(t.startsWith("http"))return t;const o=r.replace(/\/$/,""),n=t.startsWith("/")?t:`/${t}`;return`${o}${n}`},p=()=>({action:s,headers:{Authorization:`Bearer ${localStorage.getItem("token")}`}});export{c as a,p as g,s as u};

1
dist/assets/index-Cji6jJXl.css vendored Normal file

File diff suppressed because one or more lines are too long

25
dist/assets/vendor-C3mpOp0n.js vendored Normal file

File diff suppressed because one or more lines are too long

63
dist/index.html vendored Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>前端H5系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: #f5f5f5;
}
#app {
min-height: 100vh;
}
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<script type="module" crossorigin src="/assets/index-BlP3rxMf.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-C3mpOp0n.js">
<link rel="modulepreload" crossorigin href="/assets/elementPlus-DFx51bSH.js">
<link rel="stylesheet" crossorigin href="/assets/index-Cji6jJXl.css">
</head>
<body>
<div id="app">
<div class="loading">
<div class="loading-spinner"></div>
</div>
</div>
</body>
</html>

60
index.html Normal file
View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>前端H5系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: #f5f5f5;
}
#app {
min-height: 100vh;
}
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="app">
<div class="loading">
<div class="loading-spinner"></div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2588
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "frontend-h5",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2",
"element-plus": "^2.4.4",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"vue": "^3.3.11",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"sass": "^1.69.5",
"sass-embedded": "^1.89.2",
"vite": "^5.0.8"
}
}

243
src/App.vue Normal file
View File

@@ -0,0 +1,243 @@
<template>
<div id="app" :class="{ 'has-bottom-nav': showBottomNav }">
<router-view />
<BottomNav v-if="showBottomNav" />
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import BottomNav from '@/components/BottomNav.vue'
const route = useRoute()
const userStore = useUserStore()
// 定义需要显示底部导航的路由
const routesWithBottomNav = [
'/',
'/transfers',
'/matching',
'/points-history',
'/profile',
'/shop',
'/orders'
]
// 计算是否显示底部导航
const showBottomNav = computed(() => {
return routesWithBottomNav.includes(route.path)
})
onMounted(() => {
// 应用启动时检查登录状态
userStore.checkAuth()
})
</script>
<style>
#app {
min-height: 100vh;
background-color: #f5f5f5;
}
/* 全局样式 */
.page-container {
min-height: 100vh;
padding: 20px;
padding-bottom: 100px; /* 为底部导航栏留出空间 */
}
/* 为有底部导航的页面添加底部间距 */
.has-bottom-nav {
padding-bottom: 100px !important;
}
.card-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.text-center {
text-align: center;
}
.mb-20 {
margin-bottom: 20px;
}
.mt-20 {
margin-top: 20px;
}
.full-width {
width: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-container {
padding: 10px;
padding-bottom: 120px; /* 增加底部导航空间 */
}
.card-container {
padding: 15px;
margin-bottom: 15px;
border-radius: 12px;
}
/* Element Plus 组件移动端优化 */
.el-dialog {
width: 95% !important;
margin: 0 auto !important;
}
.el-form-item__label {
font-size: 14px !important;
}
.el-input__inner,
.el-textarea__inner {
font-size: 16px !important; /* 防止iOS缩放 */
}
.el-button {
min-height: 44px !important; /* 触摸友好 */
}
.el-table {
font-size: 14px !important;
}
.el-table th,
.el-table td {
padding: 8px 4px !important;
}
}
@media (max-width: 480px) {
.page-container {
padding: 8px;
padding-bottom: 120px;
}
.card-container {
padding: 12px;
margin-bottom: 12px;
}
.el-form-item__label {
font-size: 13px !important;
}
.el-button {
font-size: 14px !important;
padding: 8px 15px !important;
}
.el-table {
font-size: 12px !important;
}
}
/* Element Plus 样式覆盖 */
.el-header {
background-color: #409eff;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.el-main {
padding: 0;
}
.el-footer {
background-color: #f8f9fa;
color: #6c757d;
text-align: center;
line-height: 60px;
}
/* 自定义按钮样式 */
.btn-primary {
background-color: #409eff;
border-color: #409eff;
}
.btn-success {
background-color: #67c23a;
border-color: #67c23a;
}
.btn-warning {
background-color: #e6a23c;
border-color: #e6a23c;
}
.btn-danger {
background-color: #f56c6c;
border-color: #f56c6c;
}
/* 加载动画 */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
/* 空状态样式 */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #909399;
}
.empty-state .el-icon {
font-size: 64px;
margin-bottom: 16px;
}
/* 用户头像样式 */
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #409eff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
/* 状态标签样式 */
.status-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.status-published {
background-color: #f0f9ff;
color: #1890ff;
border: 1px solid #d1ecf1;
}
.status-draft {
background-color: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="bottom-nav">
<div
class="nav-item"
:class="{ active: isActive('/transfers') }"
@click="handleNavClick('/transfers')"
>
<Money class="nav-icon" />
<span class="nav-label">转账管理</span>
</div>
<div
class="nav-item"
:class="{ active: isActive('/matching') }"
@click="handleNavClick('/matching')"
>
<Connection class="nav-icon" />
<span class="nav-label">资金匹配</span>
</div>
<div
class="nav-item"
:class="{ active: isActive('/points-history') }"
@click="handleNavClick('/points-history')"
>
<Coin class="nav-icon" />
<span class="nav-label">积分记录</span>
</div>
<div
class="nav-item"
:class="{ active: isActive('/profile') }"
@click="handleNavClick('/profile')"
>
<User class="nav-icon" />
<span class="nav-label">个人中心</span>
</div>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Money, Coin, User, Connection } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isActive = (path) => {
return route.path === path
}
const handleNavClick = (path) => {
if (!userStore.isAuthenticated) {
router.push('/login')
return
}
router.push(path)
}
</script>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: space-around;
align-items: center;
padding: 8px 0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 12px;
min-width: 60px;
}
.nav-item:hover {
transform: translateY(-2px);
background: rgba(64, 158, 255, 0.1);
}
.nav-item:active {
transform: translateY(0);
}
.nav-item.active {
background: rgba(64, 158, 255, 0.15);
}
.nav-item.active .nav-icon {
color: #409eff;
transform: scale(1.1);
}
.nav-item.active .nav-label {
color: #409eff;
font-weight: 600;
}
.nav-icon {
font-size: 20px;
color: #909399;
margin-bottom: 4px;
transition: all 0.3s ease;
}
.nav-item:hover .nav-icon {
color: #409eff;
transform: scale(1.1);
}
.nav-label {
font-size: 12px;
color: #606266;
transition: all 0.3s ease;
text-align: center;
}
.nav-item:hover .nav-label {
color: #409eff;
}
/* 移动端适配 */
@media (max-width: 768px) {
.bottom-nav {
padding: 6px 0;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
.nav-item {
padding: 6px 8px;
min-width: 50px;
min-height: 44px; /* 触摸友好的最小尺寸 */
justify-content: center;
}
.nav-icon {
font-size: 18px;
margin-bottom: 2px;
}
.nav-label {
font-size: 11px;
line-height: 1.2;
}
}
@media (max-width: 480px) {
.bottom-nav {
padding: 4px 0;
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.nav-item {
padding: 4px 6px;
min-width: 45px;
}
.nav-icon {
font-size: 16px;
margin-bottom: 1px;
}
.nav-label {
font-size: 10px;
}
}
/* 横屏适配 */
@media (max-height: 500px) and (orientation: landscape) {
.bottom-nav {
padding: 4px 0;
}
.nav-item {
padding: 4px 8px;
}
.nav-icon {
font-size: 16px;
margin-bottom: 1px;
}
.nav-label {
font-size: 10px;
}
}
</style>

298
src/components/Captcha.vue Normal file
View File

@@ -0,0 +1,298 @@
<template>
<div class="captcha-container">
<div class="captcha-input-group">
<el-input
v-model="captchaInput"
:placeholder="placeholder"
:size="size"
clearable
@keyup.enter="$emit('verify', { captchaId, captchaText: captchaInput })"
@input="handleInput"
/>
<div class="captcha-image-wrapper" @click="refreshCaptcha">
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
class="captcha-image"
/>
<div v-else class="captcha-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div class="captcha-refresh-hint">
<el-icon><Refresh /></el-icon>
<span>点击刷新</span>
</div>
</div>
</div>
<div class="captcha-actions">
<el-button
type="text"
size="small"
@click="refreshCaptcha"
:loading="loading"
>
<el-icon><Refresh /></el-icon>
刷新验证码
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Loading } from '@element-plus/icons-vue'
import api from '@/utils/api'
// Props
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入验证码'
},
size: {
type: String,
default: 'large'
},
autoRefresh: {
type: Boolean,
default: true
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'verify', 'refresh'])
// 响应式数据
const captchaInput = ref(props.modelValue)
const captchaImage = ref('')
const captchaId = ref('')
const loading = ref(false)
/**
* 获取验证码
*/
const getCaptcha = async () => {
try {
loading.value = true
const response = await api.get('/captcha/generate')
if (response.data.success) {
captchaImage.value = response.data.data.image
captchaId.value = response.data.data.captchaId
emit('refresh', { captchaId: captchaId.value })
} else {
ElMessage.error(response.data.message || '获取验证码失败')
}
} catch (error) {
console.error('获取验证码失败:', error)
ElMessage.error('获取验证码失败,请检查网络连接')
} finally {
loading.value = false
}
}
/**
* 刷新验证码
*/
const refreshCaptcha = async () => {
captchaInput.value = ''
emit('update:modelValue', '')
await getCaptcha()
}
/**
* 处理输入变化
*/
const handleInput = (value) => {
emit('update:modelValue', value)
}
/**
* 验证验证码
* @param {string} inputText 用户输入的验证码
* @returns {Promise<boolean>} 验证结果
*/
const verifyCaptcha = async (inputText) => {
if (!captchaId.value) {
ElMessage.error('请先获取验证码')
return false
}
if (!inputText || inputText.trim() === '') {
ElMessage.error('请输入验证码')
return false
}
try {
const response = await api.post('/captcha/verify', {
captchaId: captchaId.value,
captchaText: inputText.trim()
})
if (response.data.success) {
return true
} else {
ElMessage.error(response.data.message || '验证码错误')
// 验证失败后刷新验证码
await refreshCaptcha()
return false
}
} catch (error) {
console.error('验证验证码失败:', error)
ElMessage.error('验证验证码失败,请重试')
await refreshCaptcha()
return false
}
}
/**
* 获取当前验证码信息
* @returns {Object} 验证码信息
*/
const getCaptchaInfo = () => {
return {
captchaId: captchaId.value,
captchaText: captchaInput.value
}
}
// 监听 modelValue 变化
watch(() => props.modelValue, (newValue) => {
captchaInput.value = newValue
})
// 组件挂载时获取验证码
onMounted(() => {
if (props.autoRefresh) {
getCaptcha()
}
})
// 暴露方法给父组件
defineExpose({
refreshCaptcha,
verifyCaptcha,
getCaptchaInfo
})
</script>
<style scoped>
.captcha-container {
width: 100%;
}
.captcha-input-group {
display: flex;
gap: 10px;
align-items: center;
}
.captcha-input-group .el-input {
flex: 1;
}
.captcha-image-wrapper {
position: relative;
width: 120px;
height: 40px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
overflow: hidden;
transition: all 0.3s;
background: #f5f7fa;
}
.captcha-image-wrapper:hover {
border-color: #409eff;
box-shadow: 0 0 0 1px #409eff;
}
.captcha-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 12px;
}
.captcha-loading .el-icon {
font-size: 16px;
margin-bottom: 2px;
}
.captcha-refresh-hint {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
font-size: 12px;
}
.captcha-image-wrapper:hover .captcha-refresh-hint {
opacity: 1;
}
.captcha-refresh-hint .el-icon {
font-size: 16px;
margin-bottom: 2px;
}
.captcha-actions {
margin-top: 8px;
text-align: right;
}
.captcha-actions .el-button {
padding: 0;
font-size: 12px;
color: #909399;
}
.captcha-actions .el-button:hover {
color: #409eff;
}
.captcha-actions .el-icon {
margin-right: 4px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.captcha-input-group {
flex-direction: column;
gap: 8px;
}
.captcha-image-wrapper {
width: 100%;
max-width: 200px;
height: 50px;
}
}
</style>

View File

@@ -0,0 +1,248 @@
<template>
<div class="image-upload">
<el-upload
ref="uploadRef"
class="upload-demo"
:action="uploadUrl"
:headers="uploadHeaders"
:data="uploadData"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:on-progress="handleProgress"
:show-file-list="false"
:auto-upload="true"
accept="image/*"
>
<div class="upload-area" v-if="!imageUrl">
<el-icon class="upload-icon"><Plus /></el-icon>
<div class="upload-text">{{ placeholder || '点击上传图片' }}</div>
</div>
<div class="image-preview" v-else>
<img :src="imageUrl" alt="预览图" />
<div class="image-overlay">
<el-icon class="preview-icon" @click.stop="previewImage"><ZoomIn /></el-icon>
<el-icon class="delete-icon" @click.stop="deleteImage"><Delete /></el-icon>
</div>
</div>
</el-upload>
<!-- 上传进度 -->
<el-progress
v-if="uploading"
:percentage="uploadProgress"
:stroke-width="4"
class="upload-progress"
/>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewVisible" title="图片预览" width="50%">
<img :src="imageUrl" alt="预览图" style="width: 100%; height: auto;" />
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, ZoomIn, Delete } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '点击上传图片'
},
uploadType: {
type: String,
default: 'document' // document, avatar, product
},
maxSize: {
type: Number,
default: 5 // MB
}
})
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
const userStore = useUserStore()
const uploadRef = ref()
const imageUrl = ref(props.modelValue)
const uploading = ref(false)
const uploadProgress = ref(0)
const previewVisible = ref(false)
// 上传配置
const uploadUrl = computed(() => {
return `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/upload/image`
})
const uploadHeaders = computed(() => {
const token = userStore.token
return token ? { Authorization: `Bearer ${token}` } : {}
})
const uploadData = computed(() => {
return { type: props.uploadType }
})
// 监听modelValue变化
watch(() => props.modelValue, (newVal) => {
imageUrl.value = newVal
})
// 上传前验证
const beforeUpload = (file) => {
// 检查文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
// 检查文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB`)
return false
}
uploading.value = true
uploadProgress.value = 0
return true
}
// 上传进度
const handleProgress = (event) => {
uploadProgress.value = Math.round(event.percent)
}
// 上传成功
const handleSuccess = (response) => {
uploading.value = false
uploadProgress.value = 0
if (response.success) {
imageUrl.value = response.url
emit('update:modelValue', response.url)
emit('upload-success', response)
ElMessage.success('图片上传成功')
} else {
ElMessage.error(response.message || '上传失败')
emit('upload-error', response)
}
}
// 上传失败
const handleError = (error) => {
uploading.value = false
uploadProgress.value = 0
ElMessage.error('图片上传失败,请重试')
emit('upload-error', error)
}
// 预览图片
const previewImage = () => {
previewVisible.value = true
}
// 删除图片
const deleteImage = () => {
imageUrl.value = ''
emit('update:modelValue', '')
ElMessage.success('图片已删除')
}
</script>
<style scoped>
.image-upload {
width: 100%;
}
.upload-demo {
width: 100%;
}
.upload-area {
width: 200px;
height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.3s;
}
.upload-area:hover {
border-color: #409eff;
}
.upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
color: #606266;
font-size: 14px;
}
.image-preview {
position: relative;
width: 200px;
height: 120px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.image-preview:hover .image-overlay {
opacity: 1;
}
.preview-icon,
.delete-icon {
color: white;
font-size: 20px;
margin: 0 10px;
cursor: pointer;
transition: transform 0.3s;
}
.preview-icon:hover,
.delete-icon:hover {
transform: scale(1.2);
}
.upload-progress {
margin-top: 10px;
}
</style>

48
src/config/index.js Normal file
View File

@@ -0,0 +1,48 @@
// 环境配置
const config = {
development: {
baseURL: 'http://localhost:3000',
uploadURL: import.meta.env.VITE_UPLOAD_BASE_URL || 'http://localhost:3000/api/upload'
},
production: {
baseURL: window.location.origin,
uploadURL: import.meta.env.VITE_UPLOAD_BASE_URL || `${window.location.origin}/api/upload`
}
}
// 获取当前环境
const env = import.meta.env.MODE || 'development'
// 导出当前环境的配置
export default config[env]
// 导出具体配置项
export const { baseURL, uploadURL } = config[env]
// 获取完整的图片URL
export const getImageUrl = (imagePath) => {
if (!imagePath) return ''
if (imagePath.startsWith('http')) return imagePath
// 在开发环境下直接返回相对路径让Vite代理处理
if (env === 'development') {
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
return cleanImagePath
}
// 生产环境下使用完整URL
const cleanBaseURL = baseURL.replace(/\/$/, '')
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
return `${cleanBaseURL}${cleanImagePath}`
}
// 获取上传配置
export const getUploadConfig = () => {
return {
action: uploadURL,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}
}

44
src/main.js Normal file
View File

@@ -0,0 +1,44 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import App from './App.vue'
import router from './router'
import { useUserStore } from './stores/user'
import './style.css'
// 配置NProgress
NProgress.configure({ showSpinner: false })
const app = createApp(App)
const pinia = createPinia()
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
// 应用初始化后检查用户状态
app.mount('#app')
// 初始化用户状态检查
const userStore = useUserStore()
if (userStore.isAuthenticated) {
// 如果用户已登录,启动状态检查
userStore.checkAuth().then((isValid) => {
if (isValid) {
userStore.startStatusCheck()
}
})
}

181
src/router/index.js Normal file
View File

@@ -0,0 +1,181 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import NProgress from 'nprogress'
const routes = [
{
path: '/',
name: 'Home',
redirect: '/transfers',
meta: {
title: '首页'
}
},
{
path: '/home',
name: 'HomePage',
component: () => import('@/views/Home.vue'),
meta: {
title: '首页'
}
},
{
path: '/shop',
name: 'Shop',
component: () => import('@/views/Shop.vue'),
meta: {
title: '积分商城'
}
},
{
path: '/product/:id',
name: 'ProductDetail',
component: () => import('@/views/ProductDetail.vue'),
meta: {
title: '商品详情'
}
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: {
title: '登录',
hideForAuth: true
}
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
meta: {
title: '注册',
hideForAuth: true
}
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile.vue'),
meta: {
title: '个人中心',
requiresAuth: true
}
},
{
path: '/orders',
name: 'Orders',
component: () => import('@/views/Orders.vue'),
meta: {
title: '我的订单',
requiresAuth: true
}
},
{
path: '/points-history',
name: 'PointsHistory',
component: () => import('@/views/PointsHistory.vue'),
meta: {
title: '积分记录',
requiresAuth: true
}
},
{
path: '/task-center',
name: 'TaskCenter',
component: () => import('@/views/TaskCenter.vue'),
meta: {
title: '任务中心',
requiresAuth: true
}
},
{
path: '/transfers',
name: 'Transfers',
component: () => import('@/views/Transfers.vue'),
meta: {
title: '转账',
requiresAuth: true
}
},
{
path: '/matching',
name: 'Matching',
component: () => import('@/views/Matching.vue'),
meta: {
title: '资金匹配',
requiresAuth: true
}
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue'),
meta: {
title: '关于我们'
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: {
title: '页面不存在'
}
}
]
const router = createRouter({
history: createWebHistory('/frontend/'),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
NProgress.start()
const userStore = useUserStore()
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 融互通`
}
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (!userStore.isAuthenticated) {
// 尝试从本地存储恢复登录状态
await userStore.checkAuth()
if (!userStore.isAuthenticated) {
next({
name: 'Login',
query: { redirect: to.fullPath }
})
return
}
}
}
// 如果已登录用户访问登录/注册页面,重定向到转账管理
if (to.meta.hideForAuth && userStore.isAuthenticated) {
next({ name: 'Transfers' })
return
}
next()
})
router.afterEach(() => {
NProgress.done()
})
export default router

215
src/stores/user.js Normal file
View File

@@ -0,0 +1,215 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/utils/api'
import { ElMessage } from 'element-plus'
export const useUserStore = defineStore('user', () => {
// 状态
const user = ref(null)
const token = ref(localStorage.getItem('token') || '')
const loading = ref(false)
// 计算属性
const isAuthenticated = computed(() => {
return !!token.value && !!user.value
})
const isAdmin = computed(() => {
return user.value?.role === 'admin'
})
// 设置token
const setToken = (newToken) => {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
} else {
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
}
}
// 设置用户信息
const setUser = (userData) => {
user.value = userData
}
// 登录
const login = async (credentials) => {
try {
loading.value = true
const response = await api.post('/auth/login', credentials)
if (response.data.token) {
setToken(response.data.token)
setUser(response.data.user)
startStatusCheck() // 登录成功后开始状态检查
ElMessage.success(response.data.message || '登录成功')
return { success: true, data: response.data }
}
} catch (error) {
const message = error.response?.data?.message || '登录失败'
ElMessage.error(message)
return { success: false, message }
} finally {
loading.value = false
}
}
// 注册
const register = async (userData) => {
try {
loading.value = true
const response = await api.post('/auth/register', userData)
if (response.data.token) {
setToken(response.data.token)
setUser(response.data.user)
ElMessage.success(response.data.message || '注册成功')
return { success: true, data: response.data }
}
} catch (error) {
const message = error.response?.data?.message || '注册失败'
ElMessage.error(message)
return { success: false, message }
} finally {
loading.value = false
}
}
// 登出
const logout = () => {
stopStatusCheck() // 登出时停止状态检查
setToken('')
setUser(null)
ElMessage.success('已退出登录')
}
// 检查认证状态
const checkAuth = async () => {
if (!token.value) {
return false
}
try {
// 确保请求头已设置
if (token.value && !api.defaults.headers.common['Authorization']) {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
const response = await api.get('/auth/me')
setUser(response.data.user)
return true
} catch (error) {
// token无效清除本地存储
setToken('')
setUser(null)
return false
}
}
// 更新用户信息
const updateProfile = async (userData) => {
try {
loading.value = true
const response = await api.put(`/users/${user.value.id}`, userData)
setUser(response.data.user)
ElMessage.success(response.data.message || '更新成功')
return { success: true, data: response.data }
} catch (error) {
const message = error.response?.data?.message || '更新失败'
ElMessage.error(message)
return { success: false, message }
} finally {
loading.value = false
}
}
// 修改密码
const changePassword = async (passwordData) => {
try {
loading.value = true
const response = await api.put('/auth/change-password', passwordData)
ElMessage.success(response.data.message || '密码修改成功')
return { success: true, data: response.data }
} catch (error) {
const message = error.response?.data?.message || '密码修改失败'
ElMessage.error(message)
return { success: false, message }
} finally {
loading.value = false
}
}
// 获取用户详情
const getUserInfo = async (userId) => {
try {
const response = await api.get(`/users/${userId}`)
return { success: true, data: response.data.user }
} catch (error) {
const message = error.response?.data?.message || '获取用户信息失败'
return { success: false, message }
}
}
// 定期检查用户状态(用于检测拉黑等状态变化)
let statusCheckInterval = null
/**
* 开始定期检查用户状态
* 每5分钟检查一次用户状态确保及时发现被拉黑等情况
*/
const startStatusCheck = () => {
if (statusCheckInterval) {
clearInterval(statusCheckInterval)
}
statusCheckInterval = setInterval(async () => {
if (isAuthenticated.value) {
try {
await api.get('/auth/me')
} catch (error) {
// 如果是拉黑错误API拦截器会自动处理
// 这里不需要额外处理
}
}
}, 5 * 60 * 1000) // 5分钟检查一次
}
/**
* 停止定期检查用户状态
*/
const stopStatusCheck = () => {
if (statusCheckInterval) {
clearInterval(statusCheckInterval)
statusCheckInterval = null
}
}
return {
// 状态
user,
token,
loading,
// 计算属性
isAuthenticated,
isAdmin,
// 方法
setToken,
setUser,
login,
register,
logout,
checkAuth,
updateProfile,
changePassword,
getUserInfo,
startStatusCheck,
stopStatusCheck
}
})

514
src/style.css Normal file
View File

@@ -0,0 +1,514 @@
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 移动端兼容性处理 */
html {
/* 防止iOS Safari缩放 */
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
/* 优化触摸滚动 */
-webkit-overflow-scrolling: touch;
}
body {
/* 防止元素溢出 */
overflow-x: hidden;
/* 优化字体渲染 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 防止点击高亮 */
-webkit-tap-highlight-color: transparent;
}
/* 防止所有元素水平溢出 */
* {
max-width: 100%;
}
/* 图片响应式处理 */
img {
max-width: 100%;
height: auto;
display: block;
}
/* 表格响应式处理 */
table {
width: 100%;
table-layout: fixed;
word-wrap: break-word;
}
/* 输入框防止缩放 */
input, textarea, select {
font-size: 16px; /* 防止iOS Safari自动缩放 */
max-width: 100%;
}
/* 长文本处理 */
.text-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-break {
word-wrap: break-word;
word-break: break-all;
hyphens: auto;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 链接样式 */
a {
color: #409eff;
text-decoration: none;
transition: color 0.3s;
}
a:hover {
color: #66b1ff;
}
/* 工具类 */
.clearfix::after {
content: '';
display: table;
clear: both;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
.d-block {
display: block;
}
.d-inline {
display: inline;
}
.d-inline-block {
display: inline-block;
}
.d-flex {
display: flex;
}
.d-none {
display: none;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-column {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-1 {
flex: 1;
}
/* 间距工具类 */
.m-0 { margin: 0; }
.m-5 { margin: 5px; }
.m-10 { margin: 10px; }
.m-15 { margin: 15px; }
.m-20 { margin: 20px; }
.m-30 { margin: 30px; }
.mt-0 { margin-top: 0; }
.mt-5 { margin-top: 5px; }
.mt-10 { margin-top: 10px; }
.mt-15 { margin-top: 15px; }
.mt-20 { margin-top: 20px; }
.mt-30 { margin-top: 30px; }
.mb-0 { margin-bottom: 0; }
.mb-5 { margin-bottom: 5px; }
.mb-10 { margin-bottom: 10px; }
.mb-15 { margin-bottom: 15px; }
.mb-20 { margin-bottom: 20px; }
.mb-30 { margin-bottom: 30px; }
.ml-0 { margin-left: 0; }
.ml-5 { margin-left: 5px; }
.ml-10 { margin-left: 10px; }
.ml-15 { margin-left: 15px; }
.ml-20 { margin-left: 20px; }
.ml-30 { margin-left: 30px; }
.mr-0 { margin-right: 0; }
.mr-5 { margin-right: 5px; }
.mr-10 { margin-right: 10px; }
.mr-15 { margin-right: 15px; }
.mr-20 { margin-right: 20px; }
.mr-30 { margin-right: 30px; }
.p-0 { padding: 0; }
.p-5 { padding: 5px; }
.p-10 { padding: 10px; }
.p-15 { padding: 15px; }
.p-20 { padding: 20px; }
.p-30 { padding: 30px; }
.pt-0 { padding-top: 0; }
.pt-5 { padding-top: 5px; }
.pt-10 { padding-top: 10px; }
.pt-15 { padding-top: 15px; }
.pt-20 { padding-top: 20px; }
.pt-30 { padding-top: 30px; }
.pb-0 { padding-bottom: 0; }
.pb-5 { padding-bottom: 5px; }
.pb-10 { padding-bottom: 10px; }
.pb-15 { padding-bottom: 15px; }
.pb-20 { padding-bottom: 20px; }
.pb-30 { padding-bottom: 30px; }
.pl-0 { padding-left: 0; }
.pl-5 { padding-left: 5px; }
.pl-10 { padding-left: 10px; }
.pl-15 { padding-left: 15px; }
.pl-20 { padding-left: 20px; }
.pl-30 { padding-left: 30px; }
.pr-0 { padding-right: 0; }
.pr-5 { padding-right: 5px; }
.pr-10 { padding-right: 10px; }
.pr-15 { padding-right: 15px; }
.pr-20 { padding-right: 20px; }
.pr-30 { padding-right: 30px; }
/* 宽度高度工具类 */
.w-100 { width: 100%; }
.w-75 { width: 75%; }
.w-50 { width: 50%; }
.w-25 { width: 25%; }
.h-100 { height: 100%; }
.h-75 { height: 75%; }
.h-50 { height: 50%; }
.h-25 { height: 25%; }
.min-h-100 { min-height: 100vh; }
/* 颜色工具类 */
.text-primary { color: #409eff; }
.text-success { color: #67c23a; }
.text-warning { color: #e6a23c; }
.text-danger { color: #f56c6c; }
.text-info { color: #909399; }
.text-muted { color: #c0c4cc; }
.bg-primary { background-color: #409eff; }
.bg-success { background-color: #67c23a; }
.bg-warning { background-color: #e6a23c; }
.bg-danger { background-color: #f56c6c; }
.bg-info { background-color: #909399; }
.bg-light { background-color: #f8f9fa; }
.bg-white { background-color: #ffffff; }
/* 边框工具类 */
.border { border: 1px solid #ebeef5; }
.border-top { border-top: 1px solid #ebeef5; }
.border-bottom { border-bottom: 1px solid #ebeef5; }
.border-left { border-left: 1px solid #ebeef5; }
.border-right { border-right: 1px solid #ebeef5; }
.border-0 { border: 0; }
.rounded { border-radius: 4px; }
.rounded-circle { border-radius: 50%; }
.rounded-0 { border-radius: 0; }
/* 阴影工具类 */
.shadow-sm {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.shadow {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.shadow-lg {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
}
.shadow-none {
box-shadow: none;
}
/* 过渡动画 */
.transition {
transition: all 0.3s ease;
}
.transition-fast {
transition: all 0.15s ease;
}
.transition-slow {
transition: all 0.5s ease;
}
/* 鼠标样式 */
.cursor-pointer {
cursor: pointer;
}
.cursor-not-allowed {
cursor: not-allowed;
}
/* 溢出处理 */
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
.text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 响应式隐藏 */
@media (max-width: 768px) {
.hidden-mobile {
display: none !important;
}
}
@media (min-width: 769px) {
.hidden-desktop {
display: none !important;
}
}
/* 自定义动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-in-left {
animation: slideInLeft 0.5s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-in-right {
animation: slideInRight 0.5s ease-out;
}
/* 移动端响应式工具类 */
@media (max-width: 768px) {
/* 容器适配 */
.container {
padding-left: 15px !important;
padding-right: 15px !important;
}
/* 字体大小适配 */
.mobile-text-sm { font-size: 12px !important; }
.mobile-text-base { font-size: 14px !important; }
.mobile-text-lg { font-size: 16px !important; }
.mobile-text-xl { font-size: 18px !important; }
/* 间距适配 */
.mobile-p-2 { padding: 8px !important; }
.mobile-p-4 { padding: 16px !important; }
.mobile-m-2 { margin: 8px !important; }
.mobile-m-4 { margin: 16px !important; }
/* 弹性布局适配 */
.mobile-flex-col { flex-direction: column !important; }
.mobile-flex-wrap { flex-wrap: wrap !important; }
/* 宽度适配 */
.mobile-w-full { width: 100% !important; }
.mobile-w-auto { width: auto !important; }
/* 隐藏/显示 */
.mobile-hidden { display: none !important; }
.mobile-block { display: block !important; }
/* 按钮适配 */
.mobile-btn-full {
width: 100% !important;
margin-bottom: 10px !important;
}
/* 卡片适配 */
.mobile-card-compact {
padding: 12px !important;
margin: 8px 0 !important;
border-radius: 8px !important;
}
/* 表单适配 */
.mobile-form-item {
margin-bottom: 16px !important;
}
.mobile-form-label {
font-size: 14px !important;
margin-bottom: 8px !important;
}
/* 网格适配 */
.mobile-grid-1 { grid-template-columns: 1fr !important; }
.mobile-grid-2 { grid-template-columns: repeat(2, 1fr) !important; }
/* 触摸优化 */
.mobile-touch-target {
min-height: 44px !important;
min-width: 44px !important;
}
}
/* 小屏幕设备适配 */
@media (max-width: 480px) {
.small-mobile-text-xs { font-size: 11px !important; }
.small-mobile-p-1 { padding: 4px !important; }
.small-mobile-m-1 { margin: 4px !important; }
/* 更紧凑的布局 */
.small-mobile-compact {
padding: 8px !important;
margin: 4px 0 !important;
}
}
/* 横屏适配 */
@media (max-height: 500px) and (orientation: landscape) {
.landscape-compact {
padding: 8px !important;
margin: 4px 0 !important;
}
.landscape-hidden {
display: none !important;
}
}
/* 高DPI屏幕优化 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.retina-border {
border-width: 0.5px !important;
}
}
/* 安全区域适配(刘海屏等) */
@supports (padding: max(0px)) {
.safe-area-inset-top {
padding-top: max(20px, env(safe-area-inset-top)) !important;
}
.safe-area-inset-bottom {
padding-bottom: max(20px, env(safe-area-inset-bottom)) !important;
}
.safe-area-inset-left {
padding-left: max(15px, env(safe-area-inset-left)) !important;
}
.safe-area-inset-right {
padding-right: max(15px, env(safe-area-inset-right)) !important;
}
}

242
src/utils/api.js Normal file
View File

@@ -0,0 +1,242 @@
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import router from '@/router'
// 创建axios实例
const api = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 初始化时设置token
const token = localStorage.getItem('token')
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
// 请求拦截器
let loadingInstance = null
api.interceptors.request.use(
(config) => {
// 从localStorage获取token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 显示加载动画(可选)
if (config.showLoading !== false) {
loadingInstance = ElLoading.service({
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
})
}
return config
},
(error) => {
if (loadingInstance) {
loadingInstance.close()
}
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
if (loadingInstance) {
loadingInstance.close()
}
return response
},
(error) => {
if (loadingInstance) {
loadingInstance.close()
}
// 处理不同的错误状态码
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
// 未授权清除token并跳转到登录页
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
router.push({ name: 'Login' })
ElMessage.error('登录已过期,请重新登录')
break
case 403:
// 检查是否是用户被拉黑
if (data.code === 'USER_BLACKLISTED') {
// 清除token并跳转到登录页
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
router.push({ name: 'Login' })
ElMessage.error(data.message || '账户已被拉黑,请联系管理员')
} else {
ElMessage.error(data.message || '权限不足')
}
break
case 404:
ElMessage.error(data.message || '请求的资源不存在')
break
case 422:
ElMessage.error(data.message || '请求参数错误')
break
case 429:
ElMessage.error('请求过于频繁,请稍后再试')
break
case 500:
ElMessage.error('服务器内部错误')
break
default:
ElMessage.error(data.message || '请求失败')
}
} else if (error.request) {
// 网络错误
ElMessage.error('网络连接失败,请检查网络设置')
} else {
// 其他错误
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 封装常用的请求方法
export const request = {
get: (url, config = {}) => api.get(url, config),
post: (url, data = {}, config = {}) => api.post(url, data, config),
put: (url, data = {}, config = {}) => api.put(url, data, config),
delete: (url, config = {}) => api.delete(url, config),
patch: (url, data = {}, config = {}) => api.patch(url, data, config)
}
// 用户相关API
export const userAPI = {
// 获取用户列表
getList: (params = {}) => request.get('/users', { params }),
// 获取用户详情
getDetail: (id) => request.get(`/users/${id}`),
// 更新用户信息
update: (id, data) => request.put(`/users/${id}`, data),
// 删除用户
delete: (id) => request.delete(`/users/${id}`),
// 获取用户统计
getStats: () => request.get('/users/stats/overview')
}
// 认证相关API
export const authAPI = {
// 登录
login: (data) => request.post('/auth/login', data),
// 注册
register: (data) => request.post('/auth/register', data),
// 获取当前用户信息
me: () => request.get('/auth/me'),
// 修改密码
changePassword: (data) => request.put('/auth/change-password', data)
}
// 验证码相关API
export const captchaAPI = {
// 生成验证码
generate: () => request.get('/captcha/generate'),
// 验证验证码
verify: (data) => request.post('/captcha/verify', data)
}
// 文件上传API
export const uploadAPI = {
// 上传图片
uploadImage: (file) => {
const formData = new FormData()
formData.append('image', file)
return request.post('/upload/image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 上传文件
uploadFile: (file) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/upload/file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
}
// 转账相关API
export const transferAPI = {
// 获取公户信息
getPublicAccount: () => request.get('/transfers/public-account'),
// 创建转账记录
create: (data) => {
const formData = new FormData()
Object.keys(data).forEach(key => {
formData.append(key, data[key])
})
return request.post('/transfers', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 确认转账
confirm: (id) => request.put(`/transfers/${id}/confirm`),
// 拒绝转账
reject: (id) => request.put(`/transfers/${id}/reject`),
// 确认收款
confirmReceived: (id) => request.post('/transfers/confirm-received', { transfer_id: id }),
// 确认未收到款
confirmNotReceived: (id) => request.post('/transfers/confirm-not-received', { transfer_id: id }),
// 获取用户转账记录
getUserTransfers: (params = {}) => request.get('/transfers/user', { params }),
// 获取待确认转账
getPendingTransfers: (params = {}) => request.get('/transfers/pending', { params }),
// 获取用户账户信息
getUserAccount: () => request.get('/transfers/account'),
// 获取转账列表(管理员)
getList: (params = {}) => request.get('/transfers', { params }),
// 获取转账统计
getStats: () => request.get('/transfers/stats')
}
export default api

453
src/views/About.vue Normal file
View File

@@ -0,0 +1,453 @@
<template>
<div class="about-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-left">
<el-button
type="text"
@click="$router.go(-1)"
class="back-btn"
>
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<div class="nav-center">
<h1 class="nav-title">关于我们</h1>
</div>
<div class="nav-right"></div>
</nav>
<!-- 页面内容 -->
<div class="about-content">
<!-- 网站介绍 -->
<section class="intro-section">
<div class="intro-header">
<div class="logo">
<el-icon size="60"><Platform /></el-icon>
</div>
<h2>融互通</h2>
<p class="tagline">专业的积分兑换与商品管理平台</p>
</div>
<div class="intro-content">
<p>
积分商城系统是一个现代化的积分兑换与商品管理平台致力于为用户提供丰富的商品选择和便捷的积分兑换体验
我们相信积分的价值在于为用户带来实际的收益和满足感通过技术的力量让积分兑换变得更加简单高效
</p>
</div>
</section>
<!-- 功能特色 -->
<section class="features-section">
<h3>功能特色</h3>
<div class="features-grid">
<div class="feature-item">
<div class="feature-icon">
<el-icon><ShoppingBag /></el-icon>
</div>
<h4>丰富商品</h4>
<p>精选优质商品涵盖生活用品数码产品虚拟服务等多个品类</p>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon><Coin /></el-icon>
</div>
<h4>积分兑换</h4>
<p>灵活的积分兑换机制让您的积分发挥最大价值享受购物乐趣</p>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon><TrendCharts /></el-icon>
</div>
<h4>积分管理</h4>
<p>完整的积分获取和消费记录让您清楚了解每一分积分的来源和去向</p>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon><User /></el-icon>
</div>
<h4>个人中心</h4>
<p>完善的个人资料管理记录您的兑换历程和积分成长轨迹</p>
</div>
</div>
</section>
<!-- 技术栈 -->
<section class="tech-section">
<h3>技术栈</h3>
<div class="tech-grid">
<div class="tech-category">
<h4>前端技术</h4>
<ul>
<li>Vue 3 + Composition API</li>
<li>Element Plus UI 组件库</li>
<li>Vue Router 路由管理</li>
<li>Pinia 状态管理</li>
<li>Vite 构建工具</li>
<li>响应式设计</li>
</ul>
</div>
<div class="tech-category">
<h4>后端技术</h4>
<ul>
<li>Node.js + Express</li>
<li>MySQL 数据库</li>
<li>JWT 身份认证</li>
<li>RESTful API 设计</li>
<li>积分系统管理</li>
<li>订单处理系统</li>
</ul>
</div>
</div>
</section>
<!-- 联系我们 -->
<section class="contact-section">
<h3>联系我们</h3>
<div class="contact-info">
<div class="contact-item">
<el-icon><Message /></el-icon>
<div>
<h4>邮箱</h4>
<p>contact@example.com</p>
</div>
</div>
<div class="contact-item">
<el-icon><Phone /></el-icon>
<div>
<h4>电话</h4>
<p>400-123-4567</p>
</div>
</div>
<div class="contact-item">
<el-icon><Location /></el-icon>
<div>
<h4>地址</h4>
<p>北京市朝阳区科技园区</p>
</div>
</div>
</div>
</section>
<!-- 版本信息 -->
<section class="version-section">
<div class="version-info">
<p><strong>版本</strong>v1.0.0</p>
<p><strong>更新时间</strong>{{ updateTime }}</p>
<p><strong>开发团队</strong>积分商城系统开发团队</p>
</div>
</section>
</div>
<!-- 页脚 -->
<footer class="about-footer">
<p>&copy; 2024 积分商城系统. All rights reserved.</p>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue'
import {
ArrowLeft,
Platform,
ShoppingBag,
Coin,
TrendCharts,
User,
Message,
Phone,
Location
} from '@element-plus/icons-vue'
// 响应式数据
const updateTime = ref('2024-01-15')
</script>
<style scoped>
.about-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 56px;
background: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left,
.nav-right {
flex: 1;
}
.back-btn {
color: #409eff;
font-size: 14px;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.about-content {
padding: 20px 16px;
max-width: 800px;
margin: 0 auto;
}
.intro-section {
background: white;
border-radius: 12px;
padding: 40px 30px;
margin-bottom: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.intro-header {
margin-bottom: 30px;
}
.logo {
color: #409eff;
margin-bottom: 16px;
}
.intro-header h2 {
margin: 0 0 8px 0;
font-size: 28px;
color: #333;
font-weight: 600;
}
.tagline {
margin: 0;
font-size: 16px;
color: #666;
}
.intro-content p {
font-size: 16px;
line-height: 1.8;
color: #555;
text-align: left;
margin: 0;
}
.features-section,
.tech-section,
.contact-section {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.features-section h3,
.tech-section h3,
.contact-section h3 {
margin: 0 0 24px 0;
font-size: 20px;
color: #333;
font-weight: 600;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.feature-item {
text-align: center;
padding: 20px;
border-radius: 8px;
background: #f8f9fa;
transition: all 0.3s;
}
.feature-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.feature-icon {
color: #409eff;
font-size: 32px;
margin-bottom: 12px;
}
.feature-item h4 {
margin: 0 0 8px 0;
font-size: 16px;
color: #333;
font-weight: 500;
}
.feature-item p {
margin: 0;
font-size: 14px;
color: #666;
line-height: 1.6;
}
.tech-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.tech-category h4 {
margin: 0 0 16px 0;
font-size: 16px;
color: #333;
font-weight: 500;
padding-bottom: 8px;
border-bottom: 2px solid #409eff;
}
.tech-category ul {
margin: 0;
padding: 0;
list-style: none;
}
.tech-category li {
padding: 8px 0;
color: #555;
font-size: 14px;
position: relative;
padding-left: 16px;
}
.tech-category li::before {
content: '•';
color: #409eff;
position: absolute;
left: 0;
font-weight: bold;
}
.contact-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
}
.contact-item .el-icon {
color: #409eff;
font-size: 24px;
}
.contact-item h4 {
margin: 0 0 4px 0;
font-size: 14px;
color: #333;
font-weight: 500;
}
.contact-item p {
margin: 0;
font-size: 14px;
color: #666;
}
.version-section {
background: white;
border-radius: 12px;
padding: 20px 30px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.version-info p {
margin: 8px 0;
font-size: 14px;
color: #666;
}
.about-footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.about-content {
padding: 15px 10px;
}
.intro-section,
.features-section,
.tech-section,
.contact-section {
padding: 20px 15px;
}
.intro-header h2 {
font-size: 24px;
}
.features-grid {
grid-template-columns: 1fr;
}
.tech-grid {
grid-template-columns: 1fr;
}
.contact-info {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.intro-section,
.features-section,
.tech-section,
.contact-section,
.version-section {
padding: 15px;
}
.intro-header h2 {
font-size: 20px;
}
.features-section h3,
.tech-section h3,
.contact-section h3 {
font-size: 18px;
}
}
</style>

381
src/views/Home.vue Normal file
View File

@@ -0,0 +1,381 @@
<template>
<div class="home">
<!-- 导航栏 -->
<el-header class="header">
<div class="header-content">
<div class="logo">
<h2>前端H5系统</h2>
</div>
<div class="nav-menu">
<el-menu
mode="horizontal"
:default-active="activeIndex"
class="nav-menu-items"
@select="handleMenuSelect"
>
<el-menu-item index="home">首页</el-menu-item>
<el-menu-item index="shop">积分商城</el-menu-item>
<el-menu-item index="about">关于</el-menu-item>
</el-menu>
</div>
<div class="user-actions">
<template v-if="userStore.isAuthenticated">
<el-dropdown @command="handleUserCommand">
<span class="user-info">
<div class="user-avatar">
{{ userStore.user?.username?.charAt(0)?.toUpperCase() }}
</div>
<span class="username">{{ userStore.user?.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="orders">我的订单</el-dropdown-item>
<el-dropdown-item command="points-history">积分记录</el-dropdown-item>
<el-dropdown-item command="transfers">转账管理</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<!-- 移除登录注册按钮 -->
</template>
</div>
</div>
</el-header>
<!-- 主要内容 -->
<el-main class="main-content">
<!-- 主要内容区域 -->
<div class="main-section">
<div class="container">
<div class="welcome-content">
<h2 class="welcome-title">欢迎使用前端H5系统</h2>
<p class="welcome-description">您的智能管理助手</p>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="stats-section" v-if="userStore.isAuthenticated">
<div class="container">
<h2 class="section-title">系统概览</h2>
<el-row :gutter="20">
<el-col :xs="12" :sm="6" v-for="stat in stats" :key="stat.key">
<div class="stat-card">
<div class="stat-icon">
<el-icon :size="32"><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
</el-main>
<!-- 页脚 -->
<el-footer class="footer">
<div class="container">
<p>&copy; 2024 前端H5系统. All rights reserved.</p>
</div>
</el-footer>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ArrowDown, User, View, Clock, Document, Edit, Setting, DataAnalysis, Star } from '@element-plus/icons-vue'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const activeIndex = ref('home')
const stats = ref([
{ key: 'users', label: '用户数量', value: 0, icon: 'User' },
{ key: 'orders', label: '订单总数', value: 0, icon: 'Document' },
{ key: 'products', label: '商品数量', value: 0, icon: 'Star' },
{ key: 'transfers', label: '转账记录', value: 0, icon: 'Clock' }
])
// 方法
const handleMenuSelect = (index) => {
activeIndex.value = index
switch (index) {
case 'home':
router.push('/transfers')
break
case 'shop':
router.push('/shop')
break
case 'about':
router.push('/about')
break
}
}
const handleUserCommand = (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'orders':
router.push('/orders')
break
case 'points-history':
router.push('/points-history')
break
case 'transfers':
router.push('/transfers')
break
case 'logout':
userStore.logout()
break
}
}
// 获取统计数据
const fetchStats = async () => {
try {
// 这里可以调用相关API获取统计数据
// 暂时使用模拟数据
stats.value = [
{ key: 'users', label: '用户数量', value: 156, icon: 'User' },
{ key: 'orders', label: '订单总数', value: 89, icon: 'Document' },
{ key: 'products', label: '商品数量', value: 45, icon: 'Star' },
{ key: 'transfers', label: '转账记录', value: 23, icon: 'Clock' }
]
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 生命周期
onMounted(() => {
if (userStore.isAuthenticated) {
fetchStats()
}
})
</script>
<style scoped>
.home {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.logo h2 {
color: white;
margin: 0;
}
.nav-menu-items {
background: transparent;
border: none;
}
.nav-menu-items .el-menu-item {
color: white;
border: none;
}
.nav-menu-items .el-menu-item:hover,
.nav-menu-items .el-menu-item.is-active {
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.user-actions {
display: flex;
align-items: center;
gap: 10px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
color: white;
cursor: pointer;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.main-content {
flex: 1;
padding: 0 0 80px 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.main-section,
.stats-section {
padding: 60px 0;
}
.main-section {
background-color: #f8f9fa;
min-height: 50vh;
}
.section-title {
text-align: center;
font-size: 32px;
margin-bottom: 40px;
color: #303133;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
}
.welcome-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
padding: 40px 20px;
}
.welcome-title {
font-size: 32px;
margin-bottom: 16px;
color: #303133;
font-weight: 600;
}
.welcome-description {
font-size: 16px;
color: #606266;
line-height: 1.6;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.stat-icon {
color: #409eff;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.footer {
background-color: #303133;
color: white;
text-align: center;
}
.footer .container {
padding: 20px;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 15px;
padding: 15px 20px;
}
.nav-menu {
order: 3;
width: 100%;
}
.user-actions {
order: 2;
}
.section-title {
font-size: 24px;
}
.section-header {
flex-direction: column;
gap: 20px;
text-align: center;
}
.welcome-title {
font-size: 24px;
}
.main-content {
padding-bottom: 80px;
}
}
</style>

455
src/views/Login.vue Normal file
View File

@@ -0,0 +1,455 @@
<template>
<div class="login-page">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>用户登录</h2>
<p>欢迎回到前端H5系统</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名或邮箱"
size="large"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
clearable
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item prop="captcha">
<Captcha
ref="captchaRef"
v-model="loginForm.captcha"
placeholder="请输入验证码"
size="large"
/>
</el-form-item>
<el-form-item>
<div class="form-options">
<el-checkbox v-model="rememberMe">记住我</el-checkbox>
<el-link type="primary" @click="showForgotPassword">
忘记密码
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-button"
:loading="userStore.loading"
@click="handleLogin"
>
{{ userStore.loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>
还没有账号
<el-link type="primary" @click="$router.push('/register')">
立即注册
</el-link>
</p>
</div>
<div class="quick-login">
<el-divider>快速登录</el-divider>
<div class="demo-accounts">
<el-button
type="info"
plain
size="small"
@click="quickLogin('admin')"
>
管理员账号
</el-button>
<el-button
type="success"
plain
size="small"
@click="quickLogin('user')"
>
普通用户
</el-button>
</div>
</div>
</div>
</div>
<!-- 背景装饰 -->
<div class="background-decoration">
<div class="decoration-circle circle-1"></div>
<div class="decoration-circle circle-2"></div>
<div class="decoration-circle circle-3"></div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import Captcha from '@/components/Captcha.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 表单引用
const loginFormRef = ref()
const captchaRef = ref()
// 表单数据
const loginForm = reactive({
username: '',
password: '',
captcha: ''
})
// 其他状态
const rememberMe = ref(false)
// 表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入用户名或邮箱', trigger: 'blur' },
{ min: 3, message: '用户名至少3个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 4, message: '验证码为4位字符', trigger: 'blur' }
]
}
// 处理登录
const handleLogin = async () => {
if (!loginFormRef.value || !captchaRef.value) return
try {
// 先验证表单
const valid = await loginFormRef.value.validate()
if (!valid) return
// 验证验证码
const captchaValid = await captchaRef.value.verifyCaptcha(loginForm.captcha)
if (!captchaValid) {
loginForm.captcha = ''
return
}
// 获取验证码信息
const captchaInfo = captchaRef.value.getCaptchaInfo()
// 提交登录请求(包含验证码信息)
const loginData = {
username: loginForm.username,
password: loginForm.password,
captchaId: captchaInfo.captchaId,
captchaText: captchaInfo.captchaText
}
const result = await userStore.login(loginData)
if (result.success) {
// 登录成功,跳转到目标页面或转账管理
const redirectPath = route.query.redirect || '/transfers'
router.push(redirectPath)
}
} catch (error) {
console.error('登录失败:', error)
// 登录失败后刷新验证码
if (captchaRef.value) {
await captchaRef.value.refreshCaptcha()
}
loginForm.captcha = ''
}
}
// 快速登录(演示用)
const quickLogin = async (type) => {
if (type === 'admin') {
loginForm.username = 'admin'
loginForm.password = 'admin123'
} else {
loginForm.username = 'user'
loginForm.password = 'user123'
}
// 清空验证码,让用户手动输入
loginForm.captcha = ''
ElMessage.info('请输入验证码后登录')
}
// 忘记密码
const showForgotPassword = () => {
ElMessageBox.alert(
'请联系管理员重置密码,或使用演示账号进行体验。',
'忘记密码',
{
confirmButtonText: '确定',
type: 'info'
}
)
}
// 组件挂载时的处理
onMounted(() => {
// 如果已经登录,直接跳转
if (userStore.isAuthenticated) {
const redirectPath = route.query.redirect || '/transfers'
router.push(redirectPath)
}
// 从localStorage恢复记住我状态
const savedUsername = localStorage.getItem('rememberedUsername')
if (savedUsername) {
loginForm.username = savedUsername
rememberMe.value = true
}
})
// 监听记住我状态变化
const handleRememberMe = () => {
if (rememberMe.value && loginForm.username) {
localStorage.setItem('rememberedUsername', loginForm.username)
} else {
localStorage.removeItem('rememberedUsername')
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 20px;
position: relative;
z-index: 10;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 40px 30px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
color: #303133;
margin-bottom: 8px;
font-weight: 600;
}
.login-header p {
color: #909399;
font-size: 14px;
}
.login-form {
margin-bottom: 20px;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.login-button {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 600;
}
.login-footer {
text-align: center;
margin-bottom: 20px;
}
.login-footer p {
color: #606266;
font-size: 14px;
}
.quick-login {
margin-top: 20px;
}
.demo-accounts {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 15px;
}
.background-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
.circle-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
.circle-2 {
width: 150px;
height: 150px;
top: 60%;
right: 10%;
animation-delay: 2s;
}
.circle-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
/* 响应式设计 */
@media (max-width: 480px) {
.login-container {
padding: 15px;
}
.login-card {
padding: 30px 20px;
}
.demo-accounts {
flex-direction: column;
}
.form-options {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}
/* Element Plus 组件样式覆盖 */
:deep(.el-input__wrapper) {
border-radius: 8px;
}
:deep(.el-button) {
border-radius: 8px;
}
:deep(.el-divider__text) {
background-color: rgba(255, 255, 255, 0.95);
color: #909399;
}
/* 输入框聚焦效果 */
:deep(.el-input__wrapper:hover),
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409eff inset;
}
/* 加载状态样式 */
.login-button.is-loading {
pointer-events: none;
}
/* 动画效果 */
.login-card {
animation: slideInUp 0.6s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 错误状态样式 */
:deep(.el-form-item.is-error .el-input__wrapper) {
box-shadow: 0 0 0 1px #f56c6c inset;
}
/* 成功状态样式 */
:deep(.el-form-item.is-success .el-input__wrapper) {
box-shadow: 0 0 0 1px #67c23a inset;
}
</style>

1660
src/views/Matching.vue Normal file

File diff suppressed because it is too large Load Diff

160
src/views/NotFound.vue Normal file
View File

@@ -0,0 +1,160 @@
<template>
<div class="not-found-page">
<div class="not-found-container">
<div class="error-illustration">
<div class="error-code">404</div>
<div class="error-message">页面不存在</div>
</div>
<div class="error-description">
<p>抱歉您访问的页面不存在或已被删除</p>
<p>请检查网址是否正确或返回首页继续浏览</p>
</div>
<div class="error-actions">
<el-button type="primary" @click="goHome">
<el-icon><House /></el-icon>
返回首页
</el-button>
<el-button @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回上页
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
House,
ArrowLeft
} from '@element-plus/icons-vue'
const router = useRouter()
// 方法
const goHome = () => {
router.push('/transfers')
}
const goBack = () => {
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/transfers')
}
}
</script>
<style scoped>
.not-found-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.not-found-container {
background: white;
border-radius: 16px;
padding: 40px;
max-width: 600px;
width: 100%;
text-align: center;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.error-illustration {
margin-bottom: 30px;
}
.error-code {
font-size: 120px;
font-weight: bold;
color: #667eea;
line-height: 1;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.error-message {
font-size: 24px;
color: #333;
font-weight: 500;
margin-bottom: 20px;
}
.error-description {
margin-bottom: 30px;
}
.error-description p {
color: #666;
font-size: 16px;
line-height: 1.6;
margin: 8px 0;
}
.error-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-bottom: 40px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.not-found-page {
padding: 10px;
}
.not-found-container {
padding: 30px 20px;
}
.error-code {
font-size: 80px;
}
.error-message {
font-size: 20px;
}
.error-actions {
flex-direction: column;
align-items: center;
}
.error-actions .el-button {
width: 200px;
}
}
@media (max-width: 480px) {
.error-code {
font-size: 60px;
}
.error-message {
font-size: 18px;
}
.error-description p {
font-size: 14px;
}
}
</style>

904
src/views/Orders.vue Normal file
View File

@@ -0,0 +1,904 @@
<template>
<div class="orders-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-center">
<h1 class="nav-title">我的订单</h1>
</div>
<div class="nav-right">
<el-button
type="text"
@click="$router.push('/shop')"
class="shop-btn"
>
<el-icon><ShoppingBag /></el-icon>
商城
</el-button>
</div>
</nav>
<!-- 订单状态筛选 -->
<div class="filter-tabs">
<div class="tabs-container">
<div
v-for="tab in statusTabs"
:key="tab.value"
:class="['tab-item', { active: selectedStatus === tab.value }]"
@click="selectStatus(tab.value)"
>
<span>{{ tab.label }}</span>
<el-badge
v-if="tab.count > 0"
:value="tab.count"
class="tab-badge"
/>
</div>
</div>
</div>
<!-- 订单列表 -->
<div class="orders-content">
<div v-loading="loading" class="orders-list">
<div v-if="filteredOrders.length === 0" class="empty-state">
<el-icon size="60"><Box /></el-icon>
<p>{{ getEmptyText() }}</p>
<el-button type="primary" @click="$router.push('/shop')">
去购物
</el-button>
</div>
<div v-else>
<div v-for="order in filteredOrders" :key="order.id" class="order-card">
<!-- 订单头部 -->
<div class="order-header">
<div class="order-info">
<span class="order-number">订单号{{ order.orderNumber }}</span>
<span class="order-date">{{ formatDate(order.createdAt) }}</span>
</div>
<div class="order-status">
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
</div>
</div>
<!-- 订单商品 -->
<div class="order-items">
<div
v-for="item in order.items"
:key="item.id"
class="order-item"
@click="goToProduct(item.productId)"
>
<img :src="item.product.image" :alt="item.product.name" class="item-image" />
<div class="item-info">
<h4 class="item-name">{{ item.product.name }}</h4>
<p class="item-desc">{{ truncateText(item.product.description, 40) }}</p>
<div class="item-price">
<span class="price">
<el-icon><Coin /></el-icon>
{{ item.points }}
</span>
<span class="quantity">x{{ item.quantity }}</span>
</div>
</div>
</div>
</div>
<!-- 订单总计 -->
<div class="order-total">
<div class="total-info">
<span>{{ order.totalQuantity }}件商品</span>
<span class="total-points">
总计<el-icon><Coin /></el-icon>{{ order.totalPoints }}
</span>
</div>
</div>
<!-- 订单操作 -->
<div class="order-actions">
<el-button
v-if="order.status === 'pending'"
size="small"
@click="cancelOrder(order.id)"
>
取消订单
</el-button>
<el-button
v-if="order.status === 'shipped'"
type="primary"
size="small"
@click="confirmReceive(order.id)"
>
确认收货
</el-button>
<el-button
v-if="order.status === 'completed'"
size="small"
@click="showReviewDialog(order)"
>
评价
</el-button>
<el-button
size="small"
@click="viewOrderDetail(order.id)"
>
查看详情
</el-button>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<el-button @click="loadMore" :loading="loadingMore">
加载更多
</el-button>
</div>
</div>
<!-- 评价对话框 -->
<el-dialog
v-model="showReview"
title="商品评价"
width="90%"
:before-close="handleReviewClose"
>
<div v-if="reviewOrder" class="review-form">
<div v-for="item in reviewOrder.items" :key="item.id" class="review-item">
<div class="review-product">
<img :src="item.product.image" :alt="item.product.name" class="product-image" />
<div class="product-info">
<h4>{{ item.product.name }}</h4>
<p>{{ item.product.description }}</p>
</div>
</div>
<div class="review-rating">
<span class="rating-label">评分</span>
<el-rate v-model="item.rating" size="large" />
</div>
<div class="review-content">
<el-input
v-model="item.reviewContent"
type="textarea"
:rows="3"
placeholder="请分享您的使用体验..."
maxlength="200"
show-word-limit
/>
</div>
<div class="review-images">
<el-upload
v-model:file-list="item.reviewImages"
action="#"
list-type="picture-card"
:auto-upload="false"
:limit="3"
>
<el-icon><Plus /></el-icon>
</el-upload>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showReview = false">取消</el-button>
<el-button type="primary" @click="submitReview" :loading="submittingReview">
提交评价
</el-button>
</span>
</template>
</el-dialog>
<!-- 订单详情对话框 -->
<el-dialog
v-model="showOrderDetail"
title="订单详情"
width="90%"
>
<div v-if="orderDetail" class="order-detail">
<div class="detail-section">
<h4>订单信息</h4>
<div class="detail-item">
<span class="label">订单号</span>
<span class="value">{{ orderDetail.orderNumber }}</span>
</div>
<div class="detail-item">
<span class="label">下单时间</span>
<span class="value">{{ formatDateTime(orderDetail.createdAt) }}</span>
</div>
<div class="detail-item">
<span class="label">订单状态</span>
<span class="value">
<el-tag :type="getStatusType(orderDetail.status)">
{{ getStatusText(orderDetail.status) }}
</el-tag>
</span>
</div>
</div>
<div class="detail-section">
<h4>商品信息</h4>
<div v-for="item in orderDetail.items" :key="item.id" class="detail-product">
<img :src="item.product.image" :alt="item.product.name" />
<div class="product-info">
<h5>{{ item.product.name }}</h5>
<p>{{ item.product.description }}</p>
<div class="product-price">
<span><el-icon><Coin /></el-icon>{{ item.points }} x {{ item.quantity }}</span>
</div>
</div>
</div>
</div>
<div class="detail-section">
<h4>配送信息</h4>
<div class="detail-item">
<span class="label">收货地址</span>
<span class="value">{{ orderDetail.shippingAddress || '虚拟商品,无需配送' }}</span>
</div>
<div class="detail-item">
<span class="label">物流信息</span>
<span class="value">{{ orderDetail.trackingNumber || '暂无' }}</span>
</div>
</div>
<div class="detail-section">
<h4>费用明细</h4>
<div class="detail-item">
<span class="label">商品总计</span>
<span class="value"><el-icon><Coin /></el-icon>{{ orderDetail.totalPoints }}</span>
</div>
<div class="detail-item total">
<span class="label">实付积分</span>
<span class="value"><el-icon><Coin /></el-icon>{{ orderDetail.totalPoints }}</span>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
ShoppingBag,
Box,
Coin,
Plus
} from '@element-plus/icons-vue'
import api from '@/utils/api'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const loading = ref(false)
const loadingMore = ref(false)
const selectedStatus = ref('all')
const orders = ref([])
const page = ref(1)
const hasMore = ref(true)
const showReview = ref(false)
const showOrderDetail = ref(false)
const reviewOrder = ref(null)
const orderDetail = ref(null)
const submittingReview = ref(false)
// 状态标签
const statusTabs = ref([
{ label: '全部', value: 'all', count: 0 },
{ label: '待发货', value: 'pending', count: 0 },
{ label: '已发货', value: 'shipped', count: 0 },
{ label: '已完成', value: 'completed', count: 0 },
{ label: '已取消', value: 'cancelled', count: 0 }
])
// 计算属性
const filteredOrders = computed(() => {
if (selectedStatus.value === 'all') {
return orders.value
}
return orders.value.filter(order => order.status === selectedStatus.value)
})
// 方法
const selectStatus = (status) => {
selectedStatus.value = status
}
const getEmptyText = () => {
const textMap = {
all: '暂无订单',
pending: '暂无待发货订单',
shipped: '暂无已发货订单',
completed: '暂无已完成订单',
cancelled: '暂无已取消订单'
}
return textMap[selectedStatus.value]
}
const getStatusType = (status) => {
const typeMap = {
pending: 'warning',
shipped: 'primary',
completed: 'success',
cancelled: 'danger'
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
pending: '待发货',
shipped: '已发货',
completed: '已完成',
cancelled: '已取消'
}
return textMap[status] || '未知状态'
}
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const formatDateTime = (date) => {
return new Date(date).toLocaleString('zh-CN')
}
const truncateText = (text, maxLength) => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
const goToProduct = (productId) => {
router.push(`/product/${productId}`)
}
const cancelOrder = async (orderId) => {
try {
await ElMessageBox.confirm('确定要取消这个订单吗?', '确认取消', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await api.put(`/orders/${orderId}/cancel`)
// 更新订单状态
const order = orders.value.find(o => o.id === orderId)
if (order) {
order.status = 'cancelled'
}
updateStatusCounts()
ElMessage.success('订单已取消')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('取消订单失败')
}
}
}
const confirmReceive = async (orderId) => {
try {
await ElMessageBox.confirm('确认已收到商品吗?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
await api.put(`/orders/${orderId}/receive`)
// 更新订单状态
const order = orders.value.find(o => o.id === orderId)
if (order) {
order.status = 'completed'
}
updateStatusCounts()
ElMessage.success('确认收货成功')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('确认收货失败')
}
}
}
const showReviewDialog = (order) => {
reviewOrder.value = {
...order,
items: order.items.map(item => ({
...item,
rating: 5,
reviewContent: '',
reviewImages: []
}))
}
showReview.value = true
}
const handleReviewClose = () => {
reviewOrder.value = null
showReview.value = false
}
const submitReview = async () => {
try {
submittingReview.value = true
const reviewData = {
orderId: reviewOrder.value.id,
reviews: reviewOrder.value.items.map(item => ({
productId: item.productId,
rating: item.rating,
content: item.reviewContent,
images: item.reviewImages.map(img => img.url)
}))
}
await api.post('/reviews', reviewData)
showReview.value = false
ElMessage.success('评价提交成功')
} catch (error) {
ElMessage.error('评价提交失败')
} finally {
submittingReview.value = false
}
}
const viewOrderDetail = async (orderId) => {
try {
const response = await api.get(`/orders/${orderId}`)
orderDetail.value = response.data
showOrderDetail.value = true
} catch (error) {
ElMessage.error('获取订单详情失败')
}
}
const getOrders = async (isLoadMore = false) => {
try {
if (!isLoadMore) {
loading.value = true
page.value = 1
} else {
loadingMore.value = true
}
const response = await api.get('/orders', {
params: {
page: page.value,
limit: 10
}
})
if (isLoadMore) {
orders.value.push(...response.data.orders)
} else {
orders.value = response.data.orders
}
hasMore.value = response.data.hasMore
page.value++
updateStatusCounts()
} catch (error) {
ElMessage.error('获取订单列表失败')
} finally {
loading.value = false
loadingMore.value = false
}
}
const loadMore = () => {
getOrders(true)
}
const updateStatusCounts = () => {
const counts = {
all: orders.value.length,
pending: 0,
shipped: 0,
completed: 0,
cancelled: 0
}
orders.value.forEach(order => {
counts[order.status]++
})
statusTabs.value.forEach(tab => {
tab.count = counts[tab.value]
})
}
// 生命周期
onMounted(() => {
getOrders()
})
</script>
<style scoped>
.orders-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 56px;
background: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left,
.nav-right {
flex: 1;
}
.nav-right {
display: flex;
justify-content: flex-end;
}
.back-btn,
.shop-btn {
color: #409eff;
font-size: 14px;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.filter-tabs {
background: white;
border-bottom: 1px solid #eee;
padding: 0 16px;
}
.tabs-container {
display: flex;
overflow-x: auto;
}
.tab-item {
position: relative;
padding: 16px 20px;
color: #666;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: all 0.3s;
}
.tab-item.active {
color: #409eff;
border-bottom-color: #409eff;
}
.tab-badge {
position: absolute;
top: 8px;
right: 8px;
}
.orders-content {
padding: 16px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.orders-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.order-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f5f5f5;
}
.order-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.order-number {
font-size: 14px;
color: #333;
font-weight: 500;
}
.order-date {
font-size: 12px;
color: #999;
}
.order-items {
padding: 16px;
}
.order-item {
display: flex;
gap: 12px;
padding: 8px 0;
cursor: pointer;
transition: all 0.3s;
}
.order-item:hover {
background: #f8f9fa;
border-radius: 8px;
padding: 8px;
margin: 0 -8px;
}
.item-image {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.item-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.item-name {
margin: 0;
font-size: 14px;
color: #333;
font-weight: 500;
line-height: 1.4;
}
.item-desc {
margin: 0;
font-size: 12px;
color: #666;
line-height: 1.4;
}
.item-price {
display: flex;
justify-content: space-between;
align-items: center;
}
.price {
display: flex;
align-items: center;
gap: 2px;
color: #ff6b35;
font-weight: 600;
font-size: 14px;
}
.quantity {
color: #999;
font-size: 12px;
}
.order-total {
padding: 16px;
border-top: 1px solid #f5f5f5;
border-bottom: 1px solid #f5f5f5;
}
.total-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.total-points {
display: flex;
align-items: center;
gap: 4px;
color: #ff6b35;
font-weight: 600;
font-size: 16px;
}
.order-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px;
}
.load-more {
text-align: center;
padding: 20px;
}
.review-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.review-item {
border: 1px solid #eee;
border-radius: 8px;
padding: 16px;
}
.review-product {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.review-product .product-image {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
.review-product .product-info h4 {
margin: 0 0 4px 0;
font-size: 14px;
color: #333;
}
.review-product .product-info p {
margin: 0;
font-size: 12px;
color: #666;
}
.review-rating {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.rating-label {
font-size: 14px;
color: #333;
}
.review-content {
margin-bottom: 16px;
}
.review-images {
margin-bottom: 16px;
}
.order-detail {
display: flex;
flex-direction: column;
gap: 24px;
}
.detail-section h4 {
margin: 0 0 16px 0;
font-size: 16px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.detail-item.total {
font-weight: 600;
color: #ff6b35;
border-bottom: none;
padding-top: 16px;
border-top: 1px solid #eee;
}
.detail-item .label {
color: #666;
font-size: 14px;
}
.detail-item .value {
color: #333;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
}
.detail-product {
display: flex;
gap: 12px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 8px;
}
.detail-product img {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
.detail-product .product-info h5 {
margin: 0 0 4px 0;
font-size: 14px;
color: #333;
}
.detail-product .product-info p {
margin: 0 0 8px 0;
font-size: 12px;
color: #666;
}
.detail-product .product-price {
display: flex;
align-items: center;
gap: 4px;
color: #ff6b35;
font-weight: 600;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.order-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.order-actions {
flex-wrap: wrap;
}
.detail-item {
flex-direction: column;
gap: 4px;
}
}
</style>

730
src/views/PointsHistory.vue Normal file
View File

@@ -0,0 +1,730 @@
<template>
<div class="points-history-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-center">
<h1 class="nav-title">积分记录</h1>
</div>
<div class="nav-right">
<el-button
type="text"
@click="$router.push('/shop')"
class="shop-btn"
>
<el-icon><ShoppingBag /></el-icon>
商城
</el-button>
</div>
</nav>
<!-- 积分概览 -->
<div class="points-overview">
<div class="overview-card">
<div class="current-points">
<div class="points-icon">
<el-icon size="24"><Coin /></el-icon>
</div>
<div class="points-info">
<div class="points-value">{{ userPoints }}</div>
<div class="points-label">当前积分</div>
</div>
</div>
<div class="points-stats">
<div class="stat-item">
<div class="stat-value">{{ totalEarned }}</div>
<div class="stat-label">累计获得</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ totalSpent }}</div>
<div class="stat-label">累计消费</div>
</div>
</div>
</div>
</div>
<!-- 筛选器 -->
<div class="filter-section">
<div class="filter-tabs">
<div
v-for="tab in filterTabs"
:key="tab.value"
:class="['tab-item', { active: selectedFilter === tab.value }]"
@click="selectFilter(tab.value)"
>
{{ tab.label }}
</div>
</div>
<div class="date-filter">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
@change="onDateChange"
/>
</div>
</div>
<!-- 积分记录列表 -->
<div class="history-content">
<div v-loading="loading" class="history-list">
<div v-if="filteredHistory.length === 0" class="empty-state">
<el-icon size="60"><DocumentRemove /></el-icon>
<p>{{ getEmptyText() }}</p>
<el-button type="primary" @click="$router.push('/shop')">
去赚积分
</el-button>
</div>
<div v-else>
<div v-for="record in filteredHistory" :key="record.id" class="history-item">
<div class="item-icon">
<el-icon
:size="20"
:class="getIconClass(record.type)"
>
<component :is="getIcon(record.type)" />
</el-icon>
</div>
<div class="item-content">
<div class="item-header">
<h4 class="item-title">{{ record.title }}</h4>
<div :class="['item-points', getPointsClass(record.type)]">
{{ getPointsText(record.type, record.points) }}
</div>
</div>
<div class="item-details">
<p class="item-description">{{ record.description }}</p>
<div class="item-meta">
<span class="item-date">{{ formatDateTime(record.createdAt) }}</span>
<span v-if="record.orderId" class="item-order">
订单号{{ record.orderId }}
</span>
</div>
</div>
</div>
<div class="item-action">
<el-button
v-if="record.orderId"
type="text"
size="small"
@click="viewOrder(record.orderId)"
>
查看订单
</el-button>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<el-button @click="loadMore" :loading="loadingMore">
加载更多
</el-button>
</div>
</div>
<!-- 积分规则说明 -->
<div class="points-rules">
<el-collapse v-model="activeRules">
<el-collapse-item title="积分获取规则" name="earn">
<div class="rules-content">
<div class="rule-item">
<el-icon><UserFilled /></el-icon>
<span>注册账户+100积分</span>
</div>
<div class="rule-item">
<el-icon><Calendar /></el-icon>
<span>每日签到+10积分</span>
</div>
<div class="rule-item">
<el-icon><Share /></el-icon>
<span>分享商品+5积分</span>
</div>
<div class="rule-item">
<el-icon><Star /></el-icon>
<span>商品评价+20积分</span>
</div>
<div class="rule-item">
<el-icon><Trophy /></el-icon>
<span>完成任务+50积分</span>
</div>
</div>
</el-collapse-item>
<el-collapse-item title="积分使用规则" name="spend">
<div class="rules-content">
<div class="rule-item">
<el-icon><ShoppingBag /></el-icon>
<span>商品兑换按商品标价扣除</span>
</div>
<div class="rule-item">
<el-icon><Clock /></el-icon>
<span>积分有效期永久有效</span>
</div>
<div class="rule-item">
<el-icon><Warning /></el-icon>
<span>积分不可转让不可提现</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
ShoppingBag,
Coin,
DocumentRemove,
Plus,
Minus,
Present,
ShoppingCart,
Star,
Calendar,
Share,
Trophy,
UserFilled,
Clock,
Warning
} from '@element-plus/icons-vue'
import api from '@/utils/api'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const loading = ref(false)
const loadingMore = ref(false)
const selectedFilter = ref('all')
const dateRange = ref([])
const history = ref([])
const page = ref(1)
const hasMore = ref(true)
const userPoints = ref(0)
const totalEarned = ref(0)
const totalSpent = ref(0)
const activeRules = ref([])
// 筛选标签
const filterTabs = ref([
{ label: '全部', value: 'all' },
{ label: '获得', value: 'earn' },
{ label: '消费', value: 'spend' },
{ label: '任务', value: 'task' },
{ label: '兑换', value: 'exchange' }
])
// 计算属性
const filteredHistory = computed(() => {
// 确保history.value是数组避免undefined错误
let filtered = history.value || []
// 按类型筛选
if (selectedFilter.value !== 'all') {
filtered = filtered.filter(record => record.type === selectedFilter.value)
}
// 按日期筛选
if (dateRange.value && dateRange.value.length === 2) {
const [startDate, endDate] = dateRange.value
filtered = filtered.filter(record => {
const recordDate = new Date(record.createdAt)
return recordDate >= startDate && recordDate <= endDate
})
}
return filtered
})
// 方法
const selectFilter = (filter) => {
selectedFilter.value = filter
}
const onDateChange = () => {
// 日期变化时重新加载数据
getHistory()
}
const getEmptyText = () => {
const textMap = {
all: '暂无积分记录',
earn: '暂无获得记录',
spend: '暂无消费记录',
task: '暂无任务记录',
exchange: '暂无兑换记录'
}
return textMap[selectedFilter.value]
}
const getIcon = (type) => {
const iconMap = {
earn: Plus,
spend: Minus,
task: Trophy,
exchange: ShoppingCart,
gift: Present,
review: Star,
share: Share
}
return iconMap[type] || Plus
}
const getIconClass = (type) => {
const classMap = {
earn: 'icon-earn',
spend: 'icon-spend',
task: 'icon-task',
exchange: 'icon-exchange',
gift: 'icon-present',
review: 'icon-review',
share: 'icon-share'
}
return classMap[type] || 'icon-default'
}
const getPointsClass = (type) => {
return type === 'spend' || type === 'exchange' ? 'points-negative' : 'points-positive'
}
const getPointsText = (type, points) => {
const isNegative = type === 'spend' || type === 'exchange'
return isNegative ? `-${points}` : `+${points}`
}
const formatDateTime = (date) => {
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const viewOrder = (orderId) => {
router.push(`/orders?orderId=${orderId}`)
}
const getUserPoints = async () => {
try {
const response = await api.get('/user/points')
userPoints.value = response.data.currentPoints
totalEarned.value = response.data.totalEarned
totalSpent.value = response.data.totalSpent
} catch (error) {
ElMessage.error('获取积分信息失败')
}
}
const getHistory = async (isLoadMore = false) => {
try {
if (!isLoadMore) {
loading.value = true
page.value = 1
} else {
loadingMore.value = true
}
const params = {
page: page.value,
limit: 20
}
// 添加日期筛选参数
if (dateRange.value && dateRange.value.length === 2) {
params.startDate = dateRange.value[0].toISOString()
params.endDate = dateRange.value[1].toISOString()
}
const response = await api.get('/user/points/history', { params })
// 确保响应数据是数组
const historyData = response.data.history || []
if (isLoadMore) {
history.value.push(...historyData)
} else {
history.value = historyData
}
hasMore.value = response.data.hasMore || false
page.value++
} catch (error) {
console.error('获取积分记录失败:', error)
ElMessage.error('获取积分记录失败')
// 确保在错误情况下history仍然是数组
if (!isLoadMore) {
history.value = []
}
} finally {
loading.value = false
loadingMore.value = false
}
}
const loadMore = () => {
getHistory(true)
}
// 生命周期
onMounted(() => {
getUserPoints()
getHistory()
})
</script>
<style scoped>
.points-history-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 56px;
background: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left,
.nav-right {
flex: 1;
}
.nav-right {
display: flex;
justify-content: flex-end;
}
.back-btn,
.shop-btn {
color: #409eff;
font-size: 14px;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.points-overview {
padding: 16px;
}
.overview-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
color: white;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
}
.current-points {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.points-icon {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.points-value {
font-size: 32px;
font-weight: 700;
line-height: 1;
}
.points-label {
font-size: 14px;
opacity: 0.8;
margin-top: 4px;
}
.points-stats {
display: flex;
gap: 32px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 20px;
font-weight: 600;
line-height: 1;
}
.stat-label {
font-size: 12px;
opacity: 0.8;
margin-top: 4px;
}
.filter-section {
background: white;
border-bottom: 1px solid #eee;
padding: 16px;
}
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
}
.tab-item {
padding: 8px 16px;
border-radius: 20px;
background: #f5f5f5;
color: #666;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
transition: all 0.3s;
}
.tab-item.active {
background: #409eff;
color: white;
}
.date-filter {
display: flex;
justify-content: center;
}
.history-content {
padding: 16px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-item {
display: flex;
align-items: flex-start;
gap: 12px;
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.history-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.item-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-earn {
background: #e8f5e8;
color: #52c41a;
}
.icon-spend {
background: #fff2e8;
color: #fa8c16;
}
.icon-task {
background: #f6ffed;
color: #52c41a;
}
.icon-exchange {
background: #fff0f6;
color: #eb2f96;
}
.icon-present {
background: #f9f0ff;
color: #722ed1;
}
.icon-review {
background: #fff7e6;
color: #fa8c16;
}
.icon-share {
background: #e6fffb;
color: #13c2c2;
}
.icon-default {
background: #f5f5f5;
color: #999;
}
.item-content {
flex: 1;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.item-title {
margin: 0;
font-size: 16px;
color: #333;
font-weight: 500;
line-height: 1.4;
}
.item-points {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
.points-positive {
color: #52c41a;
}
.points-negative {
color: #ff4d4f;
}
.item-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-description {
margin: 0;
font-size: 14px;
color: #666;
line-height: 1.4;
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #999;
}
.item-date {
flex: 1;
}
.item-order {
color: #409eff;
}
.item-action {
display: flex;
align-items: center;
}
.load-more {
text-align: center;
padding: 20px;
}
.points-rules {
margin: 16px;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.rules-content {
padding: 16px;
}
.rule-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 14px;
color: #666;
}
.rule-item .el-icon {
color: #409eff;
}
/* 响应式设计 */
@media (max-width: 480px) {
.points-stats {
gap: 16px;
}
.item-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.item-meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.current-points {
flex-direction: column;
text-align: center;
gap: 8px;
}
}
</style>

739
src/views/ProductDetail.vue Normal file
View File

@@ -0,0 +1,739 @@
<template>
<div class="product-detail-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-left">
<el-button
type="text"
@click="$router.go(-1)"
class="back-btn"
>
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<div class="nav-center">
<h1 class="nav-title">商品详情</h1>
</div>
<div class="nav-right">
<el-button
type="text"
@click="showCart = true"
class="cart-btn"
>
<el-badge :value="cartCount" :hidden="cartCount === 0">
<el-icon><ShoppingCart /></el-icon>
</el-badge>
</el-button>
</div>
</nav>
<div v-loading="loading" class="product-content">
<div v-if="product" class="product-detail">
<!-- 商品图片 -->
<div class="product-images">
<el-carousel
:interval="4000"
type="card"
height="300px"
indicator-position="outside"
>
<el-carousel-item v-for="(image, index) in product.images" :key="index">
<img :src="image" :alt="product.name" class="product-image" />
</el-carousel-item>
</el-carousel>
</div>
<!-- 商品信息 -->
<div class="product-info">
<div class="product-header">
<h1 class="product-title">{{ product.name }}</h1>
<div class="product-tags">
<el-tag
v-for="tag in product.tags"
:key="tag"
size="small"
class="product-tag"
>
{{ tag }}
</el-tag>
</div>
</div>
<div class="product-price">
<div class="current-price">
<el-icon><Coin /></el-icon>
<span class="price-number">{{ product.points }}</span>
<span class="price-unit">积分</span>
</div>
<div v-if="product.originalPoints" class="original-price">
原价{{ product.originalPoints }}积分
</div>
<div v-if="product.discount" class="discount-info">
<el-tag type="danger" size="small">{{ product.discount }}折优惠</el-tag>
</div>
</div>
<div class="product-stats">
<div class="stat-item">
<span class="stat-label">销量</span>
<span class="stat-value">{{ product.sales }}</span>
</div>
<div class="stat-item">
<span class="stat-label">库存</span>
<span class="stat-value">{{ product.stock }}</span>
</div>
<div class="stat-item">
<span class="stat-label">评分</span>
<span class="stat-value">
<el-rate
v-model="product.rating"
disabled
show-score
text-color="#ff9900"
score-template="{value}"
/>
</span>
</div>
</div>
<!-- 商品描述 -->
<div class="product-description">
<h3>商品描述</h3>
<p>{{ product.description }}</p>
</div>
<!-- 商品详情 -->
<div class="product-details">
<h3>商品详情</h3>
<div class="detail-item" v-for="(value, key) in product.details" :key="key">
<span class="detail-label">{{ key }}</span>
<span class="detail-value">{{ value }}</span>
</div>
</div>
<!-- 购买选项 -->
<div class="purchase-options">
<div class="quantity-selector">
<span class="option-label">数量</span>
<el-input-number
v-model="quantity"
:min="1"
:max="product.stock"
size="small"
/>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
size="large"
@click="addToCart"
:disabled="product.stock === 0"
>
加入购物车
</el-button>
<el-button
type="primary"
size="large"
@click="buyNow"
:disabled="product.stock === 0 || totalPoints > userPoints"
>
{{ product.stock === 0 ? '缺货' : totalPoints > userPoints ? '积分不足' : '立即兑换' }}
</el-button>
</div>
<!-- 商品评价 -->
<div class="product-reviews">
<div class="reviews-header">
<h3>用户评价</h3>
<span class="review-count">({{ reviews.length }}条评价)</span>
</div>
<div v-if="reviews.length === 0" class="no-reviews">
<el-icon><ChatDotRound /></el-icon>
<p>暂无评价</p>
</div>
<div v-else class="reviews-list">
<div v-for="review in reviews" :key="review.id" class="review-item">
<div class="review-header">
<div class="reviewer-info">
<el-avatar :size="32" :src="review.user.avatar">
<el-icon><User /></el-icon>
</el-avatar>
<span class="reviewer-name">{{ review.user.name }}</span>
</div>
<div class="review-meta">
<el-rate v-model="review.rating" disabled size="small" />
<span class="review-date">{{ formatDate(review.createdAt) }}</span>
</div>
</div>
<div class="review-content">
<p>{{ review.content }}</p>
<div v-if="review.images" class="review-images">
<img
v-for="(image, index) in review.images"
:key="index"
:src="image"
class="review-image"
@click="previewImage(image)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 推荐商品 -->
<div class="recommended-products">
<h3>推荐商品</h3>
<div class="recommended-grid">
<div
v-for="item in recommendedProducts"
:key="item.id"
class="recommended-item"
@click="goToProduct(item.id)"
>
<img :src="item.image" :alt="item.name" />
<div class="item-info">
<h4>{{ item.name }}</h4>
<p class="item-price">
<el-icon><Coin /></el-icon>
{{ item.points }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 购物车抽屉 -->
<el-drawer
v-model="showCart"
title="购物车"
direction="rtl"
size="80%"
>
<!-- 购物车内容复用Shop.vue的逻辑 -->
</el-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
ShoppingCart,
Coin,
ChatDotRound,
User
} from '@element-plus/icons-vue'
import api from '@/utils/api'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const loading = ref(false)
const product = ref(null)
const quantity = ref(1)
const reviews = ref([])
const recommendedProducts = ref([])
const showCart = ref(false)
const userPoints = ref(0)
const cartCount = ref(0)
// 计算属性
const totalPoints = computed(() => {
return product.value ? product.value.points * quantity.value : 0
})
// 方法
const getProductDetail = async () => {
try {
loading.value = true
const productId = route.params.id
const [productRes, reviewsRes, recommendedRes] = await Promise.all([
api.get(`/products/${productId}`),
api.get(`/products/${productId}/reviews`),
api.get(`/products/${productId}/recommended`)
])
product.value = productRes.data
reviews.value = reviewsRes.data
recommendedProducts.value = recommendedRes.data
} catch (error) {
ElMessage.error('获取商品详情失败')
router.go(-1)
} finally {
loading.value = false
}
}
const addToCart = () => {
// 添加到购物车逻辑
ElMessage.success('已添加到购物车')
cartCount.value++
}
const buyNow = async () => {
try {
await ElMessageBox.confirm(
`确定要花费 ${totalPoints.value} 积分兑换 ${quantity.value}${product.value.name} 吗?`,
'确认兑换',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const orderData = {
items: [{
productId: product.value.id,
quantity: quantity.value,
points: product.value.points
}],
totalPoints: totalPoints.value
}
await api.post('/orders', orderData)
ElMessage.success('兑换成功!')
router.push('/orders')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('兑换失败,请重试')
}
}
}
const goToProduct = (productId) => {
router.push(`/product/${productId}`)
}
const previewImage = (image) => {
// 图片预览逻辑
}
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const getUserPoints = async () => {
try {
const response = await api.get('/user/points')
userPoints.value = response.data.points
} catch (error) {
console.error('获取用户积分失败:', error)
}
}
// 生命周期
onMounted(() => {
getProductDetail()
getUserPoints()
})
</script>
<style scoped>
.product-detail-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 56px;
background: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left,
.nav-right {
flex: 1;
}
.nav-right {
display: flex;
justify-content: flex-end;
}
.back-btn,
.cart-btn {
color: #409eff;
font-size: 14px;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.product-content {
padding: 0;
}
.product-detail {
background: white;
}
.product-images {
padding: 20px;
background: white;
}
.product-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.product-info {
padding: 20px;
background: white;
border-bottom: 8px solid #f5f5f5;
}
.product-header {
margin-bottom: 16px;
}
.product-title {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: #333;
line-height: 1.4;
}
.product-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.product-tag {
margin: 0;
}
.product-price {
margin-bottom: 20px;
}
.current-price {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
}
.current-price .el-icon {
color: #ff6b35;
font-size: 20px;
}
.price-number {
font-size: 28px;
font-weight: 700;
color: #ff6b35;
}
.price-unit {
font-size: 16px;
color: #ff6b35;
}
.original-price {
color: #999;
font-size: 14px;
text-decoration: line-through;
margin-bottom: 4px;
}
.discount-info {
margin-top: 8px;
}
.product-stats {
display: flex;
gap: 24px;
padding: 16px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
margin-bottom: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #999;
}
.stat-value {
font-size: 14px;
font-weight: 500;
color: #333;
}
.product-description,
.product-details {
margin-bottom: 20px;
}
.product-description h3,
.product-details h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.product-description p {
margin: 0;
line-height: 1.6;
color: #666;
}
.detail-item {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.detail-label {
width: 80px;
color: #999;
font-size: 14px;
}
.detail-value {
flex: 1;
color: #333;
font-size: 14px;
}
.purchase-options {
margin-bottom: 20px;
}
.quantity-selector {
display: flex;
align-items: center;
gap: 12px;
}
.option-label {
font-size: 14px;
color: #333;
}
.action-buttons {
display: flex;
gap: 12px;
padding: 20px;
background: white;
border-top: 1px solid #eee;
position: sticky;
bottom: 0;
}
.action-buttons .el-button {
flex: 1;
}
.product-reviews {
padding: 20px;
background: white;
border-bottom: 8px solid #f5f5f5;
}
.reviews-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.reviews-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.review-count {
font-size: 14px;
color: #999;
}
.no-reviews {
text-align: center;
padding: 40px 20px;
color: #999;
}
.reviews-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.review-item {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.reviewer-info {
display: flex;
align-items: center;
gap: 8px;
}
.reviewer-name {
font-size: 14px;
color: #333;
font-weight: 500;
}
.review-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.review-date {
font-size: 12px;
color: #999;
}
.review-content p {
margin: 0 0 8px 0;
line-height: 1.6;
color: #666;
}
.review-images {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.review-image {
width: 60px;
height: 60px;
border-radius: 4px;
object-fit: cover;
cursor: pointer;
}
.recommended-products {
padding: 20px;
background: white;
}
.recommended-products h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: #333;
}
.recommended-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.recommended-item {
display: flex;
flex-direction: column;
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
}
.recommended-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.recommended-item img {
width: 100%;
height: 100px;
object-fit: cover;
}
.recommended-item .item-info {
padding: 8px;
}
.recommended-item h4 {
margin: 0 0 4px 0;
font-size: 12px;
color: #333;
line-height: 1.4;
}
.recommended-item .item-price {
display: flex;
align-items: center;
gap: 2px;
color: #ff6b35;
font-weight: 600;
font-size: 14px;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-stats {
gap: 16px;
}
.recommended-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.action-buttons {
padding: 16px;
}
.recommended-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

1180
src/views/Profile.vue Normal file

File diff suppressed because it is too large Load Diff

738
src/views/Register.vue Normal file
View File

@@ -0,0 +1,738 @@
<template>
<div class="register-page">
<div class="register-container">
<div class="register-card">
<div class="register-header">
<h2>用户注册</h2>
<p>创建你的账号开始使用前端H5系统</p>
</div>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="register-form"
@submit.prevent="handleRegister"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
size="large"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="phone">
<el-input
v-model="registerForm.phone"
placeholder="请输入手机号"
size="large"
:prefix-icon="Message"
clearable
/>
</el-form-item>
<el-form-item prop="smsCode">
<div class="sms-code-group">
<el-input
v-model="registerForm.smsCode"
placeholder="请输入短信验证码"
size="large"
:prefix-icon="ChatDotRound"
clearable
class="sms-input"
/>
<el-button
type="primary"
size="large"
:disabled="!canSendSMS || smsCountdown > 0"
:loading="sendingSMS"
@click="sendSMSCode"
class="sms-button"
>
{{ smsCountdown > 0 ? `${smsCountdown}s后重发` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item prop="registrationCode">
<el-input
v-model="registerForm.registrationCode"
placeholder="请输入激活码"
size="large"
:prefix-icon="Ticket"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请确认密码"
size="large"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="captcha">
<Captcha
ref="captchaRef"
v-model="registerForm.captcha"
placeholder="请输入验证码"
size="large"
/>
</el-form-item>
<el-form-item prop="agreement">
<el-checkbox v-model="registerForm.agreement">
我已阅读并同意
<el-link type="primary" @click="showAgreement">
用户协议
</el-link>
<el-link type="primary" @click="showPrivacy">
隐私政策
</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="register-button"
:loading="userStore.loading"
@click="handleRegister"
>
{{ userStore.loading ? '注册中...' : '立即注册' }}
</el-button>
</el-form-item>
</el-form>
<div class="register-footer">
<p>
已有账号
<el-link type="primary" @click="$router.push('/login')">
立即登录
</el-link>
</p>
</div>
<div class="features-preview">
<el-divider>注册后你可以</el-divider>
<div class="features-list">
<div class="feature-item">
<el-icon><User /></el-icon>
<span>个性化用户中心</span>
</div>
<div class="feature-item">
<el-icon><CreditCard /></el-icon>
<span>积分商城购物</span>
</div>
<div class="feature-item">
<el-icon><ChatDotRound /></el-icon>
<span>积分转账功能</span>
</div>
</div>
</div>
</div>
</div>
<!-- 背景装饰 -->
<div class="background-decoration">
<div class="decoration-shape shape-1"></div>
<div class="decoration-shape shape-2"></div>
<div class="decoration-shape shape-3"></div>
<div class="decoration-shape shape-4"></div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, Lock, Message, Edit, ChatDotRound, CreditCard, Ticket } from '@element-plus/icons-vue'
import Captcha from '@/components/Captcha.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 表单引用
const registerFormRef = ref()
const captchaRef = ref()
// 表单数据
const registerForm = reactive({
username: '',
phone: '',
registrationCode: '',
password: '',
confirmPassword: '',
captcha: '',
smsCode: '',
agreement: false
})
// 短信验证码相关状态
const sendingSMS = ref(false)
const smsCountdown = ref(0)
const canSendSMS = computed(() => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(registerForm.phone)
})
// 自定义验证函数
const validateUsername = (rule, value, callback) => {
if (!value) {
callback(new Error('请输入用户名'))
} else if (value.length < 3) {
callback(new Error('用户名至少3个字符'))
} else if (value.length > 20) {
callback(new Error('用户名不能超过20个字符'))
} else if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(value)) {
callback(new Error('用户名只能包含字母、数字、下划线和中文'))
} else {
callback()
}
}
const validatePassword = (rule, value, callback) => {
if (!value) {
callback(new Error('请输入密码'))
} else if (value.length < 6) {
callback(new Error('密码至少6个字符'))
} else if (value.length > 20) {
callback(new Error('密码不能超过20个字符'))
} else if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(value)) {
callback(new Error('密码必须包含字母和数字'))
} else {
// 如果确认密码已输入,重新验证确认密码
if (registerForm.confirmPassword) {
registerFormRef.value?.validateField('confirmPassword')
}
callback()
}
}
const validateConfirmPassword = (rule, value, callback) => {
if (!value) {
callback(new Error('请确认密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const validateAgreement = (rule, value, callback) => {
if (!value) {
callback(new Error('请阅读并同意用户协议和隐私政策'))
} else {
callback()
}
}
// 表单验证规则
const registerRules = {
username: [{ validator: validateUsername, trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
registrationCode: [
{ required: true, message: '请输入激活码', trigger: 'blur' },
{ min: 6, message: '激活码长度不能少于6位', trigger: 'blur' }
],
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ pattern: /^\d{6}$/, message: '短信验证码为6位数字', trigger: 'blur' }
],
password: [{ validator: validatePassword, trigger: 'blur' }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 4, message: '验证码长度为4位', trigger: 'blur' }
],
agreement: [{ validator: validateAgreement, trigger: 'change' }]
}
// 发送短信验证码
const sendSMSCode = async () => {
if (!canSendSMS.value || sendingSMS.value || smsCountdown.value > 0) {
return
}
try {
sendingSMS.value = true
const response = await fetch('/api/sms/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phone: registerForm.phone
})
})
const result = await response.json()
if (result.success) {
ElMessage.success('验证码发送成功,请查收短信')
// 开始倒计时
startCountdown()
} else {
ElMessage.error(result.message || '发送失败,请重试')
}
} catch (error) {
console.error('发送短信验证码失败:', error)
ElMessage.error('发送失败,请检查网络连接')
} finally {
sendingSMS.value = false
}
}
// 开始倒计时
const startCountdown = () => {
smsCountdown.value = 60
const timer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
}
// 处理注册
const handleRegister = async () => {
if (!registerFormRef.value || !captchaRef.value) return
try {
// 先验证表单
const valid = await registerFormRef.value.validate()
if (!valid) return
// 验证验证码
const captchaValid = await captchaRef.value.verifyCaptcha(registerForm.captcha)
if (!captchaValid) {
registerForm.captcha = ''
return
}
// 获取验证码信息
const captchaInfo = captchaRef.value.getCaptchaInfo()
// 提交注册请求(包含验证码信息)
const registerData = {
username: registerForm.username,
phone: registerForm.phone,
registrationCode: registerForm.registrationCode,
password: registerForm.password,
smsCode: registerForm.smsCode,
captchaId: captchaInfo.captchaId,
captchaText: captchaInfo.captchaText
}
const result = await userStore.register(registerData)
if (result.success) {
ElMessage.success('注册成功!请登录')
router.push('/login')
}
} catch (error) {
console.error('注册失败:', error)
// 注册失败后刷新验证码
if (captchaRef.value) {
await captchaRef.value.refreshCaptcha()
}
registerForm.captcha = ''
}
}
// 显示用户协议
const showAgreement = () => {
ElMessageBox.alert(
`<div style="text-align: left; line-height: 1.6;">
<h3>用户协议</h3>
<p>1. 用户应当遵守法律法规,不得发布违法违规内容。</p>
<p>2. 用户对自己发布的内容承担全部责任。</p>
<p>3. 平台有权对违规内容进行删除或限制。</p>
<p>4. 用户应当保护好自己的账号安全。</p>
<p>5. 平台保留修改本协议的权利。</p>
</div>`,
'用户协议',
{
confirmButtonText: '我已了解',
dangerouslyUseHTMLString: true,
customClass: 'agreement-dialog'
}
)
}
// 显示隐私政策
const showPrivacy = () => {
ElMessageBox.alert(
`<div style="text-align: left; line-height: 1.6;">
<h3>隐私政策</h3>
<p>1. 我们重视用户隐私保护。</p>
<p>2. 我们只收集必要的用户信息。</p>
<p>3. 用户信息仅用于提供服务。</p>
<p>4. 我们不会向第三方泄露用户信息。</p>
<p>5. 用户有权查看、修改或删除个人信息。</p>
</div>`,
'隐私政策',
{
confirmButtonText: '我已了解',
dangerouslyUseHTMLString: true,
customClass: 'privacy-dialog'
}
)
}
// 图片上传成功处理
const handleUploadSuccess = (response, field) => {
ElMessage.success('图片上传成功')
}
// 图片上传失败处理
const handleUploadError = (error) => {
ElMessage.error('图片上传失败,请重试')
}
// 组件挂载时的处理
onMounted(() => {
// 如果已经登录,直接跳转
if (userStore.isAuthenticated) {
const redirectPath = route.query.redirect || '/'
router.push(redirectPath)
}
})
</script>
<style lang="scss" scoped>
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.register-container {
width: 100%;
max-width: 450px;
padding: 20px;
position: relative;
z-index: 10;
}
.register-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 40px 30px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.register-header {
text-align: center;
margin-bottom: 30px;
}
.register-header h2 {
color: #303133;
margin-bottom: 8px;
font-weight: 600;
}
.register-header p {
color: #909399;
font-size: 14px;
}
.register-form {
margin-bottom: 20px;
}
.register-button {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 600;
}
.register-footer {
text-align: center;
margin-bottom: 20px;
}
.register-footer p {
color: #606266;
font-size: 14px;
}
.document-upload-section {
margin: 20px 0;
}
.document-upload-section .el-divider {
margin: 20px 0;
}
.document-upload-section .el-form-item {
margin-bottom: 20px;
}
.document-upload-section .el-form-item .el-form-item__label {
font-weight: 500;
color: #606266;
}
.features-preview {
margin-top: 20px;
}
.features-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 15px;
}
.feature-item {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
font-size: 14px;
}
.feature-item .el-icon {
color: #409eff;
font-size: 16px;
}
.upload-preview {
margin-top: 10px;
}
.upload-preview img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #dcdfe6;
}
.upload-demo {
margin-bottom: 10px;
}
.background-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.decoration-shape {
position: absolute;
background: rgba(255, 255, 255, 0.1);
animation: float 8s ease-in-out infinite;
}
.shape-1 {
width: 100px;
height: 100px;
border-radius: 50%;
top: 15%;
left: 15%;
animation-delay: 0s;
}
.shape-2 {
width: 80px;
height: 80px;
border-radius: 20px;
top: 70%;
right: 20%;
animation-delay: 2s;
}
.shape-3 {
width: 60px;
height: 60px;
border-radius: 50%;
bottom: 25%;
left: 25%;
animation-delay: 4s;
}
.shape-4 {
width: 120px;
height: 40px;
border-radius: 20px;
top: 40%;
right: 10%;
animation-delay: 6s;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
opacity: 0.7;
}
50% {
transform: translateY(-15px) rotate(180deg);
opacity: 1;
}
}
/* 响应式设计 */
@media (max-width: 480px) {
.register-container {
padding: 15px;
}
.register-card {
padding: 30px 20px;
}
.features-list {
gap: 8px;
}
}
/* Element Plus 组件样式覆盖 */
:deep(.el-input__wrapper) {
border-radius: 8px;
}
:deep(.el-button) {
border-radius: 8px;
}
:deep(.el-divider__text) {
background-color: rgba(255, 255, 255, 0.95);
color: #909399;
}
:deep(.el-checkbox__label) {
font-size: 14px;
color: #606266;
}
/* 输入框聚焦效果 */
:deep(.el-input__wrapper:hover),
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409eff inset;
}
/* 加载状态样式 */
.register-button.is-loading {
pointer-events: none;
}
/* 动画效果 */
.register-card {
animation: slideInUp 0.6s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 错误状态样式 */
:deep(.el-form-item.is-error .el-input__wrapper) {
box-shadow: 0 0 0 1px #f56c6c inset;
}
/* 成功状态样式 */
:deep(.el-form-item.is-success .el-input__wrapper) {
box-shadow: 0 0 0 1px #67c23a inset;
}
/* 协议对话框样式 */
:deep(.agreement-dialog),
:deep(.privacy-dialog) {
.el-message-box__content {
max-height: 400px;
overflow-y: auto;
}
}
/* 密码强度指示器 */
.password-strength {
margin-top: 5px;
font-size: 12px;
}
.strength-weak {
color: #f56c6c;
}
.strength-medium {
color: #e6a23c;
}
.strength-strong {
color: #67c23a;
}
/* 短信验证码样式 */
.sms-code-group {
display: flex;
gap: 12px;
align-items: center;
}
.sms-input {
flex: 1;
}
.sms-button {
flex-shrink: 0;
min-width: 120px;
height: 40px;
}
.sms-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

774
src/views/Shop.vue Normal file
View File

@@ -0,0 +1,774 @@
<template>
<div class="shop-page">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-center">
<h1 class="nav-title">积分商城</h1>
</div>
<div class="nav-right">
<el-button
type="text"
@click="$router.push('/points')"
class="points-btn"
>
<el-icon><Coin /></el-icon>
{{ userPoints }}
</el-button>
</div>
</nav>
<!-- 搜索栏 -->
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索商品"
class="search-input"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 分类筛选 -->
<div class="category-section">
<el-scrollbar>
<div class="category-list">
<div
v-for="category in categories"
:key="category.id"
:class="['category-item', { active: selectedCategory === category.id }]"
@click="selectCategory(category.id)"
>
<el-icon>{{ category.icon }}</el-icon>
<span>{{ category.name }}</span>
</div>
</div>
</el-scrollbar>
</div>
<!-- 商品列表 -->
<div class="products-section">
<div class="section-header">
<h3>热门商品</h3>
<el-dropdown @command="handleSort">
<span class="sort-btn">
{{ sortText }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="default">默认排序</el-dropdown-item>
<el-dropdown-item command="price_asc">价格从低到高</el-dropdown-item>
<el-dropdown-item command="price_desc">价格从高到低</el-dropdown-item>
<el-dropdown-item command="sales">销量优先</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div v-loading="loading" class="products-grid">
<div
v-for="product in filteredProducts"
:key="product.id"
class="product-card"
@click="goToProduct(product.id)"
>
<div class="product-image">
<img :src="product.image" :alt="product.name" />
<div v-if="product.discount" class="discount-badge">
{{ product.discount }}
</div>
</div>
<div class="product-info">
<h4 class="product-name">{{ product.name }}</h4>
<p class="product-desc">{{ truncateText(product.description, 50) }}</p>
<div class="product-price">
<span class="current-price">
<el-icon><Coin /></el-icon>
{{ product.points }}
</span>
<span v-if="product.originalPoints" class="original-price">
{{ product.originalPoints }}
</span>
</div>
<div class="product-stats">
<span class="sales">已售 {{ product.sales }}</span>
<span class="stock">库存 {{ product.stock }}</span>
</div>
</div>
<div class="product-actions">
<el-button
type="primary"
size="small"
@click.stop="addToCart(product)"
:disabled="product.stock === 0"
>
{{ product.stock === 0 ? '缺货' : '兑换' }}
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && filteredProducts.length === 0" class="empty-state">
<el-icon size="60"><Box /></el-icon>
<p>暂无商品</p>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<el-button @click="loadMore" :loading="loadingMore">
加载更多
</el-button>
</div>
</div>
<!-- 购物车悬浮按钮 -->
<div class="cart-fab" @click="showCart = true">
<el-badge :value="cartCount" :hidden="cartCount === 0">
<el-icon size="24"><ShoppingCart /></el-icon>
</el-badge>
</div>
<!-- 购物车抽屉 -->
<el-drawer
v-model="showCart"
title="购物车"
direction="rtl"
size="80%"
>
<div class="cart-content">
<div v-if="cartItems.length === 0" class="empty-cart">
<el-icon size="60"><ShoppingCart /></el-icon>
<p>购物车是空的</p>
</div>
<div v-else>
<div v-for="item in cartItems" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name" class="item-image" />
<div class="item-info">
<h4>{{ item.name }}</h4>
<p class="item-price">
<el-icon><Coin /></el-icon>
{{ item.points }}
</p>
</div>
<div class="item-actions">
<el-input-number
v-model="item.quantity"
:min="1"
:max="item.stock"
size="small"
@change="updateCartItem(item)"
/>
<el-button
type="danger"
size="small"
@click="removeFromCart(item.id)"
>
删除
</el-button>
</div>
</div>
<div class="cart-footer">
<div class="total-points">
总计<el-icon><Coin /></el-icon>{{ totalPoints }}
</div>
<el-button
type="primary"
size="large"
@click="checkout"
:disabled="totalPoints > userPoints"
>
{{ totalPoints > userPoints ? '积分不足' : '立即兑换' }}
</el-button>
</div>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
Coin,
Search,
ArrowDown,
Box,
ShoppingCart
} from '@element-plus/icons-vue'
import api from '@/utils/api'
import { debounce } from 'lodash-es'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const loading = ref(false)
const loadingMore = ref(false)
const searchKeyword = ref('')
const selectedCategory = ref('all')
const sortBy = ref('default')
const products = ref([])
const page = ref(1)
const hasMore = ref(true)
const showCart = ref(false)
const cartItems = ref([])
// 用户积分
const userPoints = ref(0)
// 分类数据
const categories = ref([
{ id: 'all', name: '全部', icon: '🛍️' },
{ id: 'electronics', name: '数码', icon: '📱' },
{ id: 'books', name: '图书', icon: '📚' },
{ id: 'lifestyle', name: '生活', icon: '🏠' },
{ id: 'food', name: '美食', icon: '🍔' },
{ id: 'sports', name: '运动', icon: '⚽' },
{ id: 'beauty', name: '美妆', icon: '💄' }
])
// 计算属性
const filteredProducts = computed(() => {
let result = products.value
// 分类筛选
if (selectedCategory.value !== 'all') {
result = result.filter(p => p.category === selectedCategory.value)
}
// 搜索筛选
if (searchKeyword.value) {
result = result.filter(p =>
p.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
p.description.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
}
// 排序
switch (sortBy.value) {
case 'price_asc':
result.sort((a, b) => a.points - b.points)
break
case 'price_desc':
result.sort((a, b) => b.points - a.points)
break
case 'sales':
result.sort((a, b) => b.sales - a.sales)
break
}
return result
})
const sortText = computed(() => {
const sortMap = {
default: '默认排序',
price_asc: '价格从低到高',
price_desc: '价格从高到低',
sales: '销量优先'
}
return sortMap[sortBy.value]
})
const cartCount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + item.quantity, 0)
})
const totalPoints = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.points * item.quantity), 0)
})
// 方法
const selectCategory = (categoryId) => {
selectedCategory.value = categoryId
}
const handleSort = (command) => {
sortBy.value = command
}
const handleSearch = debounce(() => {
// 搜索逻辑已在计算属性中处理
}, 300)
const goToProduct = (productId) => {
router.push(`/product/${productId}`)
}
const addToCart = (product) => {
const existingItem = cartItems.value.find(item => item.id === product.id)
if (existingItem) {
if (existingItem.quantity < product.stock) {
existingItem.quantity++
ElMessage.success('已添加到购物车')
} else {
ElMessage.warning('库存不足')
}
} else {
cartItems.value.push({
...product,
quantity: 1
})
ElMessage.success('已添加到购物车')
}
}
const updateCartItem = (item) => {
// 数量更新逻辑
}
const removeFromCart = (productId) => {
const index = cartItems.value.findIndex(item => item.id === productId)
if (index > -1) {
cartItems.value.splice(index, 1)
ElMessage.success('已从购物车移除')
}
}
const checkout = async () => {
try {
await ElMessageBox.confirm(
`确定要花费 ${totalPoints.value} 积分兑换这些商品吗?`,
'确认兑换',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const orderData = {
items: cartItems.value.map(item => ({
productId: item.id,
quantity: item.quantity,
points: item.points
})),
totalPoints: totalPoints.value
}
await api.post('/orders', orderData)
// 清空购物车
cartItems.value = []
showCart.value = false
// 更新用户积分
userPoints.value -= totalPoints.value
ElMessage.success('兑换成功!')
router.push('/orders')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('兑换失败,请重试')
}
}
}
const getProducts = async (isLoadMore = false) => {
try {
if (!isLoadMore) {
loading.value = true
page.value = 1
} else {
loadingMore.value = true
}
const response = await api.get('/products', {
params: {
page: page.value,
limit: 20,
category: selectedCategory.value === 'all' ? '' : selectedCategory.value,
keyword: searchKeyword.value,
sort: sortBy.value
}
})
if (isLoadMore) {
products.value.push(...response.data.products)
} else {
products.value = response.data.products
}
hasMore.value = response.data.hasMore
page.value++
} catch (error) {
ElMessage.error('获取商品列表失败')
} finally {
loading.value = false
loadingMore.value = false
}
}
const loadMore = () => {
getProducts(true)
}
const getUserPoints = async () => {
try {
const response = await api.get('/user/points')
userPoints.value = response.data.points
} catch (error) {
console.error('获取用户积分失败:', error)
}
}
const truncateText = (text, maxLength) => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
// 生命周期
onMounted(() => {
getProducts()
getUserPoints()
})
</script>
<style scoped>
.shop-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 56px;
background: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left,
.nav-right {
flex: 1;
}
.nav-right {
display: flex;
justify-content: flex-end;
}
.back-btn,
.points-btn {
color: #409eff;
font-size: 14px;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.search-section {
padding: 16px;
background: white;
border-bottom: 1px solid #eee;
}
.search-input {
width: 100%;
}
.category-section {
background: white;
padding: 16px 0;
border-bottom: 1px solid #eee;
}
.category-list {
display: flex;
gap: 16px;
padding: 0 16px;
white-space: nowrap;
}
.category-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
min-width: 60px;
}
.category-item:hover {
background: #f0f9ff;
}
.category-item.active {
background: #409eff;
color: white;
}
.category-item span {
font-size: 12px;
}
.products-section {
padding: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.sort-btn {
display: flex;
align-items: center;
gap: 4px;
color: #666;
font-size: 14px;
cursor: pointer;
}
.products-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.product-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.product-image {
position: relative;
width: 100%;
height: 120px;
overflow: hidden;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.discount-badge {
position: absolute;
top: 8px;
right: 8px;
background: #ff4757;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.product-info {
padding: 12px;
}
.product-name {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.product-desc {
margin: 0 0 8px 0;
font-size: 12px;
color: #666;
line-height: 1.4;
}
.product-price {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.current-price {
display: flex;
align-items: center;
gap: 2px;
color: #ff6b35;
font-weight: 600;
font-size: 16px;
}
.original-price {
color: #999;
font-size: 12px;
text-decoration: line-through;
}
.product-stats {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
margin-bottom: 8px;
}
.product-actions {
padding: 0 12px 12px;
}
.product-actions .el-button {
width: 100%;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.load-more {
text-align: center;
padding: 20px;
}
.cart-fab {
position: fixed;
bottom: 80px;
right: 20px;
width: 56px;
height: 56px;
background: #409eff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
z-index: 1000;
}
.cart-content {
height: 100%;
display: flex;
flex-direction: column;
}
.empty-cart {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
}
.cart-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #eee;
}
.item-image {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
.item-info {
flex: 1;
}
.item-info h4 {
margin: 0 0 4px 0;
font-size: 14px;
color: #333;
}
.item-price {
display: flex;
align-items: center;
gap: 2px;
color: #ff6b35;
font-weight: 600;
margin: 0;
}
.item-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.cart-footer {
margin-top: auto;
padding: 20px 0;
border-top: 1px solid #eee;
}
.total-points {
display: flex;
align-items: center;
gap: 4px;
font-size: 18px;
font-weight: 600;
color: #ff6b35;
margin-bottom: 16px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.products-grid {
grid-template-columns: 1fr;
}
.product-card {
display: flex;
}
.product-image {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.product-info {
flex: 1;
}
}
</style>

645
src/views/TaskCenter.vue Normal file
View File

@@ -0,0 +1,645 @@
<template>
<div class="task-center">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-left">
<el-button
type="text"
@click="$router.go(-1)"
class="back-btn"
>
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<div class="nav-center">
<h1 class="nav-title">任务中心</h1>
</div>
<div class="nav-right">
<el-button
type="text"
@click="$router.push('/points-history')"
class="points-btn"
>
<el-icon><Coin /></el-icon>
{{ userPoints }}
</el-button>
</div>
</nav>
<!-- 任务统计 -->
<div class="task-stats">
<div class="stats-card">
<div class="stat-item">
<div class="stat-icon">
<el-icon><Trophy /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ completedTasks }}</div>
<div class="stat-label">已完成</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ pendingTasks }}</div>
<div class="stat-label">进行中</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">
<el-icon><Star /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ totalRewards }}</div>
<div class="stat-label">总积分</div>
</div>
</div>
</div>
</div>
<!-- 任务分类 -->
<div class="task-categories">
<div class="category-tabs">
<div
v-for="category in categories"
:key="category.key"
:class="['category-tab', { active: activeCategory === category.key }]"
@click="activeCategory = category.key"
>
<el-icon>{{ category.icon }}</el-icon>
<span>{{ category.name }}</span>
</div>
</div>
</div>
<!-- 任务列表 -->
<div class="task-list">
<div
v-for="task in filteredTasks"
:key="task.id"
:class="['task-item', {
completed: task.status === 'completed',
claimed: task.status === 'claimed'
}]"
>
<div class="task-icon">
<el-icon :size="24">{{ getTaskIcon(task.type) }}</el-icon>
</div>
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-desc">{{ task.description }}</div>
<div class="task-progress" v-if="task.progress !== undefined">
<el-progress
:percentage="(task.progress / task.target) * 100"
:show-text="false"
:stroke-width="4"
/>
<span class="progress-text">{{ task.progress }}/{{ task.target }}</span>
</div>
</div>
<div class="task-reward">
<div class="reward-points">+{{ task.points }}</div>
<el-button
v-if="task.status === 'pending'"
type="primary"
size="small"
@click="doTask(task)"
:loading="task.loading"
>
{{ getTaskButtonText(task) }}
</el-button>
<el-button
v-else-if="task.status === 'completed'"
type="success"
size="small"
@click="claimReward(task)"
:loading="task.loading"
>
领取奖励
</el-button>
<el-tag
v-else-if="task.status === 'claimed'"
type="success"
size="small"
>
已领取
</el-tag>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredTasks.length === 0" class="empty-state">
<el-icon :size="60" color="#c0c4cc"><DocumentRemove /></el-icon>
<p>暂无任务</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
Coin,
Trophy,
Clock,
Star,
Calendar,
ShoppingCart,
Share,
User,
CreditCard,
Present,
DocumentRemove
} from '@element-plus/icons-vue'
import api from '@/utils/api'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const userPoints = ref(0)
const activeCategory = ref('daily')
const tasks = ref([])
const loading = ref(false)
// 任务分类
const categories = ref([
{ key: 'daily', name: '每日任务', icon: Calendar },
{ key: 'shopping', name: '购物任务', icon: ShoppingCart },
{ key: 'social', name: '社交任务', icon: Share },
{ key: 'profile', name: '完善资料', icon: User },
{ key: 'special', name: '特殊任务', icon: Present }
])
// 计算属性
const filteredTasks = computed(() => {
return tasks.value.filter(task => task.category === activeCategory.value)
})
const completedTasks = computed(() => {
return tasks.value.filter(task => task.status === 'claimed').length
})
const pendingTasks = computed(() => {
return tasks.value.filter(task => task.status === 'pending' || task.status === 'completed').length
})
const totalRewards = computed(() => {
return tasks.value
.filter(task => task.status === 'claimed')
.reduce((total, task) => total + task.points, 0)
})
// 方法
/**
* 获取任务图标
*/
const getTaskIcon = (type) => {
const iconMap = {
purchase: ShoppingCart,
share: Share,
profile: User,
transfer: CreditCard,
invite: User,
review: Star
}
return iconMap[type] || Present
}
/**
* 获取任务按钮文本
*/
const getTaskButtonText = (task) => {
const textMap = {
purchase: '去购买',
share: '去分享',
profile: '去完善',
transfer: '去转账',
invite: '去邀请',
review: '去评价'
}
return textMap[task.type] || '去完成'
}
/**
* 执行任务
*/
const doTask = async (task) => {
task.loading = true
try {
switch (task.type) {
case 'purchase':
router.push('/shop')
break
case 'share':
await shareApp()
break
case 'profile':
router.push('/profile')
break
case 'transfer':
router.push('/transfers')
break
case 'invite':
await showInviteDialog()
break
case 'review':
router.push('/orders')
break
default:
// 直接完成任务
await completeTask(task.id)
break
}
} catch (error) {
ElMessage.error('操作失败,请重试')
} finally {
task.loading = false
}
}
/**
* 分享应用
*/
const shareApp = async () => {
if (navigator.share) {
try {
await navigator.share({
title: '融互通 - 资金互助平台',
text: '发现一个很棒的资金互助平台,快来看看吧!',
url: window.location.origin
})
// 完成分享任务
await completeTask('share_app')
ElMessage.success('分享成功!')
} catch (error) {
if (error.name !== 'AbortError') {
ElMessage.error('分享失败')
}
}
} else {
// 复制链接到剪贴板
try {
await navigator.clipboard.writeText(window.location.origin)
await completeTask('share_app')
ElMessage.success('链接已复制到剪贴板!')
} catch (error) {
ElMessage.error('复制失败')
}
}
}
/**
* 显示邀请对话框
*/
const showInviteDialog = async () => {
// 这里可以实现邀请功能
ElMessage.info('邀请功能开发中...')
}
/**
* 完成任务
*/
const completeTask = async (taskId) => {
try {
const response = await api.post(`/tasks/${taskId}/complete`)
// 更新任务状态
const task = tasks.value.find(t => t.id === taskId)
if (task) {
task.status = 'completed'
}
ElMessage.success('任务完成!')
} catch (error) {
ElMessage.error('任务完成失败')
}
}
/**
* 领取奖励
*/
const claimReward = async (task) => {
task.loading = true
try {
const response = await api.post(`/tasks/${task.id}/claim`)
// 更新任务状态和用户积分
task.status = 'claimed'
userPoints.value += task.points
ElMessage.success(`获得 ${task.points} 积分!`)
} catch (error) {
ElMessage.error('领取失败,请重试')
} finally {
task.loading = false
}
}
/**
* 获取任务列表
*/
const getTasks = async () => {
loading.value = true
try {
const response = await api.get('/tasks')
tasks.value = response.data.map(task => ({
...task,
loading: false
}))
} catch (error) {
// 如果API不存在使用模拟数据
tasks.value = [
{
id: 'first_purchase',
category: 'shopping',
type: 'purchase',
title: '首次购买',
description: '在积分商城完成首次购买',
points: 50,
status: 'pending',
loading: false
},
{
id: 'share_app',
category: 'social',
type: 'share',
title: '分享应用',
description: '分享应用给朋友',
points: 20,
status: 'pending',
loading: false
},
{
id: 'complete_profile',
category: 'profile',
type: 'profile',
title: '完善个人资料',
description: '完善头像、姓名等个人信息',
points: 30,
status: 'pending',
progress: 2,
target: 5,
loading: false
},
{
id: 'first_transfer',
category: 'special',
type: 'transfer',
title: '首次转账',
description: '完成首次转账操作',
points: 100,
status: 'completed',
loading: false
}
]
} finally {
loading.value = false
}
}
/**
* 获取用户积分
*/
const getUserPoints = async () => {
try {
const response = await api.get('/user/points')
userPoints.value = response.data.points
} catch (error) {
console.error('获取用户积分失败:', error)
}
}
// 生命周期
onMounted(() => {
getTasks()
getUserPoints()
})
</script>
<style scoped>
.task-center {
min-height: 100vh;
background: #f5f7fa;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 56px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-left,
.nav-right {
flex: 1;
}
.nav-right {
display: flex;
justify-content: flex-end;
}
.back-btn,
.points-btn {
color: #333;
font-size: 14px;
}
.points-btn {
color: #ff6b35;
font-weight: bold;
}
.nav-title {
margin: 0;
font-size: 18px;
font-weight: 500;
text-align: center;
}
.task-stats {
padding: 16px;
}
.stats-card {
background: white;
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-around;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-bottom: 8px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #666;
}
.task-categories {
padding: 0 16px 16px;
}
.category-tabs {
display: flex;
background: white;
border-radius: 12px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.category-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
font-size: 12px;
color: #666;
}
.category-tab.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.category-tab .el-icon {
margin-bottom: 4px;
}
.task-list {
padding: 0 16px;
}
.task-item {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.task-item.completed {
border-left: 4px solid #67c23a;
}
.task-item.claimed {
opacity: 0.6;
border-left: 4px solid #909399;
}
.task-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-right: 16px;
}
.task-content {
flex: 1;
}
.task-title {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.task-desc {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.task-progress {
display: flex;
align-items: center;
gap: 8px;
}
.task-progress .el-progress {
flex: 1;
}
.progress-text {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.task-reward {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.reward-points {
font-size: 14px;
font-weight: bold;
color: #ff6b35;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state p {
margin-top: 16px;
font-size: 14px;
}
</style>

1433
src/views/Transfers.vue Normal file

File diff suppressed because it is too large Load Diff

38
vite.config.js Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
elementPlus: ['element-plus']
}
}
}
}
})