Initial commit: 骑手端

This commit is contained in:
notyclaw 2026-03-27 20:28:06 +08:00
commit ba174ce81e
48 changed files with 4851 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

1
dist/assets/Home-B6h_k9SP.js vendored Normal file

File diff suppressed because one or more lines are too long

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

@ -0,0 +1 @@
.home-page[data-v-7557b155]{min-height:100%;background:#f5f5f5;padding-bottom:20px}.header[data-v-7557b155]{background:linear-gradient(135deg,#07c160,#06ad56);padding:20px;color:#fff;display:flex;justify-content:space-between;align-items:center}.user-info[data-v-7557b155]{display:flex;align-items:center;gap:12px}.user-info h3[data-v-7557b155]{margin:0 0 4px;font-size:18px}.user-info p[data-v-7557b155]{margin:0;font-size:14px;opacity:.9}.wallet[data-v-7557b155]{background:#fff3;padding:8px 16px;border-radius:20px;text-align:center}.wallet-label[data-v-7557b155]{display:block;font-size:12px;opacity:.9}.wallet-value[data-v-7557b155]{font-size:18px;font-weight:700}.banner[data-v-7557b155]{background:linear-gradient(135deg,#ff6b35,#ff8f6b);margin:12px;border-radius:16px;padding:20px;color:#fff;display:flex;justify-content:space-between;align-items:center}.banner h2[data-v-7557b155]{font-size:22px;margin:0 0 8px}.banner p[data-v-7557b155]{font-size:14px;opacity:.9;margin:0 0 12px}.banner-icon[data-v-7557b155]{font-size:60px}.section[data-v-7557b155]{margin:16px 12px}.section-header[data-v-7557b155]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.section-header h3[data-v-7557b155]{font-size:18px;color:#333;margin:0}.vehicle-list[data-v-7557b155]{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.vehicle-card[data-v-7557b155]{background:#fff;border-radius:12px;padding:12px;cursor:pointer;transition:transform .2s}.vehicle-card[data-v-7557b155]:active{transform:scale(.98)}.vehicle-img[data-v-7557b155]{font-size:48px;text-align:center;padding:10px 0}.vehicle-info h4[data-v-7557b155]{font-size:16px;margin:0 0 4px;color:#333}.vehicle-desc[data-v-7557b155]{font-size:12px;color:#999;margin:0 0 8px}.vehicle-price[data-v-7557b155]{display:flex;justify-content:space-between;align-items:center}.price[data-v-7557b155]{color:#ff6b35;font-weight:700;font-size:16px}.quick-actions[data-v-7557b155]{display:flex;background:#fff;margin:16px 12px;border-radius:12px;padding:16px}.action-item[data-v-7557b155]{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;cursor:pointer}.action-item span[data-v-7557b155]{font-size:12px;color:#666}.order-card[data-v-7557b155]{background:#fff;border-radius:12px;padding:16px;cursor:pointer}.order-card.active[data-v-7557b155]{border-left:4px solid #07c160}.order-header[data-v-7557b155]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.order-id[data-v-7557b155]{font-size:13px;color:#666}.order-body[data-v-7557b155]{display:flex;gap:12px;margin-bottom:12px}.vehicle-mini[data-v-7557b155]{display:flex;flex-direction:column;align-items:center;gap:4px}.vehicle-mini .icon[data-v-7557b155]{font-size:32px}.vehicle-mini .model[data-v-7557b155]{font-size:12px;color:#666}.order-detail[data-v-7557b155]{flex:1}.order-detail p[data-v-7557b155]{margin:0 0 4px;font-size:13px;color:#333}.order-detail .amount[data-v-7557b155]{color:#ff6b35;font-weight:700}.order-footer[data-v-7557b155]{display:flex;gap:8px;justify-content:flex-end}

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

@ -0,0 +1 @@
.login-page[data-v-b5e11a1f]{min-height:100vh;background:linear-gradient(135deg,#07c160,#06ad56);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px}.login-header[data-v-b5e11a1f]{text-align:center;color:#fff;margin-bottom:40px}.logo[data-v-b5e11a1f]{font-size:64px;margin-bottom:10px}.login-header h1[data-v-b5e11a1f]{font-size:28px;margin-bottom:8px}.login-header p[data-v-b5e11a1f]{font-size:14px;opacity:.9}.login-form[data-v-b5e11a1f]{width:100%;max-width:360px;background:#fff;border-radius:16px;padding:30px;box-shadow:0 10px 40px #0003}.login-tabs[data-v-b5e11a1f] .el-tabs__header{margin-bottom:20px}.login-tabs[data-v-b5e11a1f] .el-tabs__item{font-size:16px}.login-btn[data-v-b5e11a1f]{width:100%;background:#07c160;border-color:#07c160;font-size:18px;margin-top:20px}.demo-hint[data-v-b5e11a1f]{text-align:center;color:#999;font-size:12px;margin-top:20px}

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

@ -0,0 +1 @@
import{_ as N,o as T,c as $,a as n,b as o,w as l,r as u,d as i,u as E,e as _,p as y,l as k,f as S,t as B,E as c,n as I}from"./index-BjjMa8ds.js";import{r as R}from"./request-DB47Z7vM.js";const D={class:"login-page"},L={class:"login-form"},J={__name:"Login",setup(O){const w=E(),g=u("password"),v=u(!1),d=u(0),h=u(),b=u(),p=u({phone:"13800138000",code:"123456"}),s=u({phone:"13800138000",password:"123456"}),C={phone:[{required:!0,message:"请输入手机号",trigger:"blur"}],code:[{required:!0,message:"请输入验证码",trigger:"blur"}]},F={phone:[{required:!0,message:"请输入手机号",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}]},U=()=>{if(!p.value.phone){c.warning("请输入手机号");return}c.success("验证码已发送"),d.value=60;const m=setInterval(()=>{d.value--,d.value<=0&&clearInterval(m)},1e3)},q=async()=>{try{await(g.value==="phone"?h.value:b.value).validate(),v.value=!0;const e=await R.post("/customers/login",{phone:s.value.phone,password:s.value.password}).catch(async()=>{const a=await R.get("/customers");if(a.success&&a.data){const t=a.data.find(f=>f.phone===s.value.phone);if(t)return{success:!0,data:t}}throw new Error("登录失败")});if(e.success&&e.data){const a=e.data,t=btoa(`${a._id}:${Date.now()}`);localStorage.setItem("customer_token",t),localStorage.setItem("customer_info",JSON.stringify(a)),localStorage.setItem("customer_id",a._id),c.success("登录成功"),await I(),w.push("/")}else c.error(e.message||"登录失败")}catch{if(s.value.phone==="13800138000"&&s.value.password==="123456"){const e={_id:"69be3bd27d8c99477626018b",customerId:"CUST001",name:"张三",phone:"13800138000",balance:500,totalRentals:5,creditScore:100},a=btoa(`${e._id}:${Date.now()}`);localStorage.setItem("customer_token",a),localStorage.setItem("customer_info",JSON.stringify(e)),localStorage.setItem("customer_id",e._id),c.success("登录成功(演示模式)"),await I(),w.push("/")}else c.error("手机号或密码错误")}finally{v.value=!1}};return(m,e)=>{const a=i("el-input"),t=i("el-form-item"),f=i("el-button"),V=i("el-form"),x=i("el-tab-pane"),z=i("el-tabs");return T(),$("div",D,[e[7]||(e[7]=n("div",{class:"login-header"},[n("div",{class:"logo"},"🛵"),n("h1",null,"租车用户端"),n("p",null,"电动车租赁平台")],-1)),n("div",L,[o(z,{modelValue:g.value,"onUpdate:modelValue":e[4]||(e[4]=r=>g.value=r),class:"login-tabs"},{default:l(()=>[o(x,{label:"手机号登录",name:"phone"},{default:l(()=>[o(V,{ref_key:"phoneFormRef",ref:h,model:p.value,rules:C},{default:l(()=>[o(t,{prop:"phone"},{default:l(()=>[o(a,{modelValue:p.value.phone,"onUpdate:modelValue":e[0]||(e[0]=r=>p.value.phone=r),placeholder:"请输入手机号",size:"large","prefix-icon":_(y)},null,8,["modelValue","prefix-icon"])]),_:1}),o(t,{prop:"code"},{default:l(()=>[o(a,{modelValue:p.value.code,"onUpdate:modelValue":e[1]||(e[1]=r=>p.value.code=r),placeholder:"请输入验证码",size:"large","prefix-icon":_(k),style:{width:"60%"}},{append:l(()=>[o(f,{onClick:U,disabled:d.value>0},{default:l(()=>[S(B(d.value>0?`${d.value}s`:"获取验证码"),1)]),_:1},8,["disabled"])]),_:1},8,["modelValue","prefix-icon"])]),_:1})]),_:1},8,["model"])]),_:1}),o(x,{label:"密码登录",name:"password"},{default:l(()=>[o(V,{ref_key:"pwdFormRef",ref:b,model:s.value,rules:F},{default:l(()=>[o(t,{prop:"phone"},{default:l(()=>[o(a,{modelValue:s.value.phone,"onUpdate:modelValue":e[2]||(e[2]=r=>s.value.phone=r),placeholder:"请输入手机号",size:"large","prefix-icon":_(y)},null,8,["modelValue","prefix-icon"])]),_:1}),o(t,{prop:"password"},{default:l(()=>[o(a,{modelValue:s.value.password,"onUpdate:modelValue":e[3]||(e[3]=r=>s.value.password=r),type:"password",placeholder:"请输入密码",size:"large","prefix-icon":_(k),"show-password":""},null,8,["modelValue","prefix-icon"])]),_:1})]),_:1},8,["model"])]),_:1})]),_:1},8,["modelValue"]),o(f,{type:"primary",size:"large",loading:v.value,class:"login-btn",onClick:q},{default:l(()=>[...e[5]||(e[5]=[S(" 登录 ",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=n("div",{class:"demo-hint"},[n("p",null,"演示账号13800138000 / 123456")],-1))])])}}},A=N(J,[["__scopeId","data-v-b5e11a1f"]]);export{A as default};

1
dist/assets/Orders-BdBnNQ0z.css vendored Normal file
View File

@ -0,0 +1 @@
.orders-page[data-v-5975620a]{min-height:100vh;background:#f5f5f5}.page-header[data-v-5975620a]{background:linear-gradient(135deg,#07c160,#06ad56);padding:20px;color:#fff}.page-header h2[data-v-5975620a]{margin:0;font-size:22px}.status-tabs[data-v-5975620a]{display:flex;background:#fff;padding:12px 0}.tab-item[data-v-5975620a]{flex:1;text-align:center;font-size:14px;color:#666;cursor:pointer;position:relative;padding-bottom:8px}.tab-item.active[data-v-5975620a]{color:#07c160;font-weight:700}.tab-item.active[data-v-5975620a]:after{content:"";position:absolute;bottom:0;left:50%;transform:translate(-50%);width:40px;height:3px;background:#07c160;border-radius:2px}.count[data-v-5975620a]{display:inline-block;background:#ff6b35;color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;margin-left:4px}.order-list[data-v-5975620a]{padding:12px}.order-card[data-v-5975620a]{background:#fff;border-radius:12px;padding:16px;margin-bottom:12px}.order-header[data-v-5975620a]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.order-number[data-v-5975620a]{font-size:13px;color:#666}.order-body[data-v-5975620a]{cursor:pointer}.vehicle-info[data-v-5975620a]{display:flex;align-items:center;gap:12px;padding:12px;background:#f5f5f5;border-radius:8px;margin-bottom:12px}.vehicle-info .icon[data-v-5975620a]{font-size:36px}.vehicle-info h4[data-v-5975620a]{margin:0;font-size:16px}.vehicle-info p[data-v-5975620a]{margin:4px 0 0;font-size:12px;color:#999}.order-dates p[data-v-5975620a]{margin:0 0 6px;font-size:13px;color:#333}.order-dates .label[data-v-5975620a]{color:#999;margin-right:8px}.order-footer[data-v-5975620a]{display:flex;justify-content:space-between;align-items:center;margin-top:12px;padding-top:12px;border-top:1px solid #f5f5f5}.amount .label[data-v-5975620a]{color:#999;font-size:12px}.amount .value[data-v-5975620a]{color:#ff6b35;font-size:18px;font-weight:700;margin-left:4px}.actions[data-v-5975620a]{display:flex;gap:8px}.order-detail[data-v-5975620a]{padding:10px 0}.detail-section[data-v-5975620a]{margin-bottom:16px}.detail-section h4[data-v-5975620a]{margin:0 0 10px;font-size:15px;color:#333;padding-bottom:8px;border-bottom:1px solid #eee}.detail-row[data-v-5975620a]{display:flex;justify-content:space-between;margin-bottom:8px;font-size:14px}.detail-row .label[data-v-5975620a]{color:#999}.detail-row .value[data-v-5975620a]{color:#333}.detail-row.total[data-v-5975620a]{margin-top:12px;padding-top:12px;border-top:1px solid #eee;font-weight:700}.detail-row.total .value[data-v-5975620a]{color:#ff6b35;font-size:16px}

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

File diff suppressed because one or more lines are too long

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

@ -0,0 +1 @@
.profile-page[data-v-ff4839a8]{min-height:100vh;background:#f5f5f5;padding-bottom:20px}.profile-header[data-v-ff4839a8]{background:linear-gradient(135deg,#07c160,#06ad56);padding:24px 16px;color:#fff;display:flex;align-items:center;gap:16px;position:relative}.avatar[data-v-ff4839a8]{flex-shrink:0}.user-info[data-v-ff4839a8]{flex:1}.user-info h3[data-v-ff4839a8]{margin:0 0 4px;font-size:20px}.user-info p[data-v-ff4839a8]{margin:0 0 6px;font-size:13px;opacity:.9}.credit[data-v-ff4839a8]{display:flex;align-items:center;gap:8px;font-size:12px}.settings-btn[data-v-ff4839a8]{position:absolute;top:16px;right:16px;color:#fff!important}.wallet-card[data-v-ff4839a8]{background:#fff;margin:12px;border-radius:12px;padding:16px}.wallet-main[data-v-ff4839a8]{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px}.wallet-main .label[data-v-ff4839a8]{color:#666;font-size:14px}.wallet-main .balance[data-v-ff4839a8]{color:#ff6b35;font-size:28px;font-weight:700}.wallet-actions[data-v-ff4839a8]{display:flex;gap:12px}.wallet-actions .el-button[data-v-ff4839a8]:first-child{flex:1;background:#07c160;border-color:#07c160}.stats-card[data-v-ff4839a8]{background:#fff;margin:0 12px;border-radius:12px;padding:16px;display:flex;justify-content:space-around;align-items:center}.stat-item[data-v-ff4839a8]{text-align:center}.stat-item .value[data-v-ff4839a8]{display:block;font-size:20px;font-weight:700;color:#333}.stat-item .label[data-v-ff4839a8]{font-size:12px;color:#999}.stat-divider[data-v-ff4839a8]{width:1px;height:40px;background:#eee}.menu-list[data-v-ff4839a8]{background:#fff;margin:12px;border-radius:12px;overflow:hidden}.menu-item[data-v-ff4839a8]{display:flex;align-items:center;padding:14px 16px;cursor:pointer;transition:background .2s}.menu-item[data-v-ff4839a8]:active{background:#f5f5f5}.menu-item span[data-v-ff4839a8]{flex:1;margin-left:12px;font-size:15px;color:#333}.menu-item .el-icon[data-v-ff4839a8]:last-child{color:#ccc}.recharge-dialog[data-v-ff4839a8]{padding:10px 0}.amount-input[data-v-ff4839a8]{display:flex;align-items:center;gap:8px;margin-bottom:16px}.amount-input .yuan[data-v-ff4839a8]{font-size:24px;color:#333}.quick-amounts[data-v-ff4839a8]{display:flex;gap:10px;justify-content:center}.amount-tag[data-v-ff4839a8]{cursor:pointer;padding:8px 16px}.withdraw-dialog[data-v-ff4839a8]{padding:10px 0}.withdraw-dialog .hint[data-v-ff4839a8]{text-align:center;color:#666;margin-bottom:16px}.transaction-list[data-v-ff4839a8]{max-height:400px;overflow-y:auto}.transaction-item[data-v-ff4839a8]{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #f5f5f5}.tx-type[data-v-ff4839a8]{display:block;font-size:14px;color:#333}.tx-date[data-v-ff4839a8]{font-size:12px;color:#999}.tx-amount[data-v-ff4839a8]{font-size:16px;font-weight:700}.tx-amount.expense[data-v-ff4839a8]{color:#333}.tx-amount.income[data-v-ff4839a8]{color:#07c160}.settings-list[data-v-ff4839a8]{padding:10px 0}.settings-item[data-v-ff4839a8]{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #f5f5f5}.about-content[data-v-ff4839a8]{text-align:center;padding:20px 0}.app-logo[data-v-ff4839a8]{font-size:64px;margin-bottom:12px}.about-content h3[data-v-ff4839a8]{margin:0 0 8px}.about-content p[data-v-ff4839a8]{margin:0 0 4px;color:#666}.about-content .desc[data-v-ff4839a8]{margin-top:12px;font-size:13px}

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

File diff suppressed because one or more lines are too long

1
dist/assets/TabLayout-BkhKizJZ.js vendored Normal file
View File

@ -0,0 +1 @@
import{_ as p,o as s,c as n,a,b as c,F as d,g as h,h as v,d as l,i as f,w as m,j as b,k,t as y,m as C,v as w,q as B,s as g}from"./index-BjjMa8ds.js";const x={class:"tab-layout"},L={class:"content"},z={class:"tab-bar"},D=["onClick"],F={__name:"TabLayout",setup(N){const o=v(),r=[{path:"/",label:"首页",icon:C},{path:"/vehicles",label:"租车",icon:w},{path:"/orders",label:"订单",icon:B},{path:"/profile",label:"我的",icon:g}],i=t=>t==="/"?o.path==="/":o.path.startsWith(t);return(t,T)=>{const _=l("router-view"),u=l("el-icon");return s(),n("div",x,[a("div",L,[c(_)]),a("div",z,[(s(),n(d,null,h(r,e=>a("div",{key:e.path,class:f(["tab-item",{active:i(e.path)}]),onClick:V=>t.$router.push(e.path)},[c(u,{size:24},{default:m(()=>[(s(),b(k(e.icon)))]),_:2},1024),a("span",null,y(e.label),1)],10,D)),64))])])}}},j=p(F,[["__scopeId","data-v-b4bf5f5f"]]);export{j as default};

1
dist/assets/TabLayout-G7-kxjSl.css vendored Normal file
View File

@ -0,0 +1 @@
.tab-layout[data-v-b4bf5f5f]{min-height:100vh;display:flex;flex-direction:column;background:#f5f5f5}.content[data-v-b4bf5f5f]{flex:1;overflow-y:auto;padding-bottom:60px}.tab-bar[data-v-b4bf5f5f]{position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;display:flex;box-shadow:0 -2px 10px #0000001a;z-index:100}.tab-item[data-v-b4bf5f5f]{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#999;cursor:pointer;transition:color .2s}.tab-item span[data-v-b4bf5f5f]{font-size:12px;margin-top:2px}.tab-item.active[data-v-b4bf5f5f]{color:#07c160}

View File

@ -0,0 +1 @@
.vehicle-detail[data-v-35bb3c09]{min-height:100vh;background:#f5f5f5;padding-bottom:80px}.detail-header[data-v-35bb3c09]{background:linear-gradient(135deg,#07c160,#06ad56);padding:12px 8px;color:#fff;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}.detail-header span[data-v-35bb3c09]{font-size:16px}.vehicle-image[data-v-35bb3c09]{position:relative;height:200px;background:#fff;display:flex;align-items:center;justify-content:center}.image-placeholder[data-v-35bb3c09]{font-size:120px}.status-badge[data-v-35bb3c09]{position:absolute;top:12px;right:12px;padding:4px 12px;border-radius:12px;font-size:12px;color:#fff}.status-badge.空闲[data-v-35bb3c09]{background:#07c160}.status-badge.在租[data-v-35bb3c09]{background:#ff6b35}.detail-content[data-v-35bb3c09]{padding:12px}.info-card[data-v-35bb3c09]{background:#fff;border-radius:12px;padding:16px;margin-bottom:12px}.info-card h2[data-v-35bb3c09]{margin:0 0 12px;font-size:22px;color:#333}.info-card h3[data-v-35bb3c09]{margin:0 0 12px;font-size:16px;color:#333}.tags[data-v-35bb3c09]{display:flex;gap:8px}.price-list[data-v-35bb3c09]{display:flex;flex-direction:column;gap:12px}.price-item[data-v-35bb3c09]{display:flex;justify-content:space-between;align-items:center}.price-item .label[data-v-35bb3c09]{color:#666;font-size:14px}.price-item .value[data-v-35bb3c09]{color:#333;font-size:16px}.price-item .value.primary[data-v-35bb3c09]{color:#ff6b35;font-size:20px;font-weight:700}.info-list[data-v-35bb3c09]{display:flex;flex-direction:column;gap:10px}.info-row[data-v-35bb3c09]{display:flex;justify-content:space-between}.info-row .label[data-v-35bb3c09]{color:#666}.info-row .value[data-v-35bb3c09]{color:#333}.rules p[data-v-35bb3c09]{margin:0 0 8px;font-size:13px;color:#666;line-height:1.6}.bottom-action[data-v-35bb3c09]{position:fixed;bottom:0;left:0;right:0;background:#fff;padding:12px 16px;display:flex;align-items:center;gap:12px;box-shadow:0 -2px 10px #0000001a}.price-info[data-v-35bb3c09]{flex:1}.price-info .total[data-v-35bb3c09]{display:block;font-size:20px;color:#ff6b35;font-weight:700}.price-info .hint[data-v-35bb3c09]{font-size:12px;color:#999}.bottom-action .el-button[data-v-35bb3c09]{flex:1;background:#07c160;border-color:#07c160}.rent-dialog[data-v-35bb3c09]{padding:10px 0}.rent-vehicle[data-v-35bb3c09]{display:flex;align-items:center;gap:12px;padding:12px;background:#f5f5f5;border-radius:8px;margin-bottom:16px}.rent-vehicle .icon[data-v-35bb3c09]{font-size:40px}.rent-vehicle h4[data-v-35bb3c09]{margin:0;font-size:16px}.rent-vehicle p[data-v-35bb3c09]{margin:4px 0 0;font-size:13px;color:#999}.rent-summary[data-v-35bb3c09]{margin-top:16px;padding-top:16px;border-top:1px solid #eee}.summary-row[data-v-35bb3c09]{display:flex;justify-content:space-between;margin-bottom:8px;font-size:14px;color:#666}.summary-row.total[data-v-35bb3c09]{margin-top:12px;padding-top:12px;border-top:1px solid #eee;font-size:16px;color:#333;font-weight:700}.dialog-footer[data-v-35bb3c09]{display:flex;gap:10px;justify-content:center}

1
dist/assets/VehicleDetail-D5D7hejI.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Vehicles-B6M9xiAW.css vendored Normal file
View File

@ -0,0 +1 @@
.vehicles-page[data-v-48ac9c3a]{min-height:100vh;background:#f5f5f5;padding-bottom:20px}.page-header[data-v-48ac9c3a]{background:linear-gradient(135deg,#07c160,#06ad56);padding:20px;color:#fff}.page-header h2[data-v-48ac9c3a]{margin:0 0 4px;font-size:22px}.page-header p[data-v-48ac9c3a]{margin:0;font-size:14px;opacity:.9}.filter-bar[data-v-48ac9c3a]{display:flex;gap:10px;padding:12px;background:#fff}.vehicle-list[data-v-48ac9c3a]{padding:0 12px}.vehicle-card[data-v-48ac9c3a]{background:#fff;border-radius:12px;padding:16px;margin-bottom:12px;display:flex;align-items:center;cursor:pointer;transition:transform .2s}.vehicle-card[data-v-48ac9c3a]:active{transform:scale(.98)}.vehicle-img[data-v-48ac9c3a]{position:relative;width:80px;height:80px;background:#f5f5f5;border-radius:8px;display:flex;align-items:center;justify-content:center}.vehicle-img .emoji[data-v-48ac9c3a]{font-size:40px}.status-tag[data-v-48ac9c3a]{position:absolute;top:-8px;right:-8px}.vehicle-info[data-v-48ac9c3a]{flex:1;margin-left:12px}.vehicle-info h4[data-v-48ac9c3a]{margin:0 0 6px;font-size:16px;color:#333}.tags[data-v-48ac9c3a]{display:flex;gap:4px;margin-bottom:8px}.price-row[data-v-48ac9c3a]{display:flex;align-items:baseline;gap:12px}.price[data-v-48ac9c3a]{color:#ff6b35;font-size:18px;font-weight:700}.deposit[data-v-48ac9c3a]{color:#999;font-size:12px}.vehicle-arrow[data-v-48ac9c3a]{color:#ccc}

1
dist/assets/Vehicles-DEebcfqf.js vendored Normal file
View File

@ -0,0 +1 @@
import{_ as z,x as D,o as i,c as _,a as t,b as s,w as c,D as C,F as M,g as B,j as N,z as j,r as v,d as n,G as F,H as I,u as R,f as y,t as r,e as S,I as U}from"./index-BjjMa8ds.js";import{r as $}from"./request-DB47Z7vM.js";const q={class:"vehicles-page"},E={class:"filter-bar"},G={class:"vehicle-list"},H=["onClick"],L={class:"vehicle-img"},A={class:"vehicle-info"},J={class:"tags"},K={class:"price-row"},O={class:"price"},Q={class:"deposit"},W={class:"vehicle-arrow"},X={__name:"Vehicles",setup(Y){const b=R(),u=v(!1),m=v([]),d=v(""),p=v(""),V=l=>{const a=l.purchasePrice||3e3;return Math.round(a/100)},f=I(()=>m.value.filter(l=>{const a=!d.value||l.batteryType===d.value,o=!p.value||l.status===p.value;return a&&o})),w=async()=>{u.value=!0;try{const l=await $.get("/vehicles");l.success&&(m.value=l.data)}catch{m.value=[{_id:"1",model:"黑骑士",color:"黑色",batteryType:"锂电池",purchasePrice:3500,status:"空闲"},{_id:"2",model:"黑骑士",color:"白色",batteryType:"锂电池",purchasePrice:3500,status:"空闲"},{_id:"3",model:"电动车",color:"蓝色",batteryType:"铅酸电池",purchasePrice:2800,status:"空闲"},{_id:"4",model:"高端豪车",color:"红色",batteryType:"锂电池",purchasePrice:8e3,status:"空闲"},{_id:"5",model:"普通标准套餐",color:"绿色",batteryType:"铅酸电池",purchasePrice:2500,status:"在租"}]}finally{u.value=!1}},P=l=>{b.push(`/vehicle/${l}`)};return D(()=>{w()}),(l,a)=>{const o=n("el-option"),g=n("el-select"),h=n("el-tag"),T=n("el-icon"),x=n("el-empty"),k=F("loading");return i(),_("div",q,[a[3]||(a[3]=t("div",{class:"page-header"},[t("h2",null,"选择车型"),t("p",null,"浏览全部可用车辆")],-1)),t("div",E,[s(g,{modelValue:d.value,"onUpdate:modelValue":a[0]||(a[0]=e=>d.value=e),placeholder:"电池类型",size:"small",style:{width:"100px"}},{default:c(()=>[s(o,{label:"全部",value:""}),s(o,{label:"锂电池",value:"锂电池"}),s(o,{label:"铅酸电池",value:"铅酸电池"})]),_:1},8,["modelValue"]),s(g,{modelValue:p.value,"onUpdate:modelValue":a[1]||(a[1]=e=>p.value=e),placeholder:"状态",size:"small",style:{width:"100px"}},{default:c(()=>[s(o,{label:"全部",value:""}),s(o,{label:"空闲",value:"空闲"}),s(o,{label:"在租",value:"在租"})]),_:1},8,["modelValue"])]),C((i(),_("div",G,[(i(!0),_(M,null,B(f.value,e=>(i(),_("div",{key:e._id,class:"vehicle-card",onClick:Z=>P(e._id)},[t("div",L,[a[2]||(a[2]=t("span",{class:"emoji"},"🛵",-1)),s(h,{type:e.status==="空闲"?"success":"warning",class:"status-tag",size:"small"},{default:c(()=>[y(r(e.status),1)]),_:2},1032,["type"])]),t("div",A,[t("h4",null,r(e.model),1),t("div",J,[s(h,{size:"small",type:"info"},{default:c(()=>[y(r(e.color),1)]),_:2},1024),s(h,{size:"small",type:"info"},{default:c(()=>[y(r(e.batteryType),1)]),_:2},1024)]),t("div",K,[t("span",O,"¥"+r(V(e))+"/天",1),t("span",Q,"押金: ¥"+r(e.purchasePrice?Math.round(e.purchasePrice*.1):200),1)])]),t("div",W,[s(T,null,{default:c(()=>[s(S(U))]),_:1})])],8,H))),128)),!u.value&&f.value.length===0?(i(),N(x,{key:0,description:"暂无车辆"})):j("",!0)])),[[k,u.value]])])}}},se=z(X,[["__scopeId","data-v-48ac9c3a"]]);export{se as default};

51
dist/assets/index-BjjMa8ds.js vendored Normal file

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

6
dist/assets/request-DB47Z7vM.js vendored Normal file

File diff suppressed because one or more lines are too long

19
dist/index.html vendored Normal file
View File

@ -0,0 +1,19 @@
<!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" />
<meta name="theme-color" content="#07c160" />
<title>租车用户端 - 电动车租赁</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
<script type="module" crossorigin src="/assets/index-BjjMa8ds.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwgJcKt6.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

BIN
home-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!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" />
<meta name="theme-color" content="#FF6B00" />
<title>租车用户端 - 电动车租赁</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

BIN
login-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
orders-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

1876
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "e-scooter-rider-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5176",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.3",
"vue": "^3.5.25",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"playwright": "^1.58.2",
"vite": "^7.3.1"
}
}

BIN
profile-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
screenshots/01-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
screenshots/02-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
screenshots/03-vehicles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
screenshots/05-orders.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
screenshots/06-profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

46
src/App.vue Normal file
View File

@ -0,0 +1,46 @@
<template>
<router-view />
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #FFF7F0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
min-height: 100vh;
background: #FFF7F0;
}
/* 橙色风格按钮 */
.weui-btn {
background: #FF6B00;
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
}
.weui-btn:active {
opacity: 0.9;
}
/* 橙色风格卡片 */
.weui-card {
background: #fff;
border-radius: 16px;
padding: 16px;
margin: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
</style>

18
src/main.js Normal file
View File

@ -0,0 +1,18 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/orange-theme.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.mount('#app')

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

@ -0,0 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
component: () => import('../views/TabLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('../views/Home.vue') },
{ path: 'vehicles', name: 'Vehicles', component: () => import('../views/Vehicles.vue') },
{ path: 'vehicle/:id', name: 'VehicleDetail', component: () => import('../views/VehicleDetail.vue') },
{ path: 'orders', name: 'Orders', component: () => import('../views/Orders.vue') },
{ path: 'profile', name: 'Profile', component: () => import('../views/Profile.vue') }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('customer_token')
if (to.path !== '/login' && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/')
} else {
next()
}
})
export default router

View File

@ -0,0 +1,98 @@
/* ============================================
橙色主题 - 全局样式覆盖骑手端
Orange Theme - Global Overrides (Rider)
============================================ */
/* Element Plus 主色调覆盖为橙色 */
:root {
--el-color-primary: #FF6B00;
--el-color-primary-light-3: #FF8F40;
--el-color-primary-light-5: #FFA860;
--el-color-primary-light-7: #FFBF80;
--el-color-primary-light-8: #FFD0A0;
--el-color-primary-light-9: #FFE0C0;
--el-color-primary-dark-2: #CC5700;
}
/* 全局背景色改为暖白 */
body {
background: #FFF7F0 !important;
}
#app {
background: #FFF7F0 !important;
}
/* TabBar 激活态 */
.tab-item.active {
color: #FF6B00 !important;
}
/* Tab 下划线激活色 */
.filter-tab.active {
color: #FF6B00 !important;
}
.filter-tab.active::after {
background-color: #FF6B00 !important;
}
/* Element Plus Tabs */
.el-tabs__item.is-active {
color: #FF6B00 !important;
}
.el-tabs__active-bar {
background-color: #FF6B00 !important;
}
/* Element Plus 按钮主色 */
.el-button--primary {
--el-button-bg-color: #FF6B00;
--el-button-border-color: #FF6B00;
--el-button-hover-bg-color: #FF8C00;
--el-button-hover-border-color: #FF8C00;
--el-button-active-bg-color: #E65C00;
--el-button-active-border-color: #E65C00;
}
/* Element Plus Tag success 类型 */
.el-tag--success {
--el-tag-bg-color: #FFF0E0;
--el-tag-text-color: #FF6B00;
--el-tag-border-color: #FFD0A0;
}
/* Element Plus 消息成功色 */
.el-message--success .el-message__content {
color: #FF6B00;
}
/* 状态 badge */
.status-active,
.status-success {
background: #FFF0E0 !important;
color: #FF6B00 !important;
}
/* loading spinner */
.loading-ring {
border-top-color: #FF6B00 !important;
}
/* Tab count badge */
.count {
background: #FF6B00 !important;
}
/* 钱包余额 */
.wallet-value,
.balance {
color: #FF6B00 !important;
}
/* 钱包背景 */
.wallet,
.wallet-card {
background: #FFF0E0 !important;
}

34
src/utils/request.js Normal file
View File

@ -0,0 +1,34 @@
import axios from 'axios'
import router from '../router'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器
request.interceptors.request.use(
config => {
const token = localStorage.getItem('customer_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器
request.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('customer_token')
localStorage.removeItem('customer_info')
router.push('/login')
}
return Promise.reject(error)
}
)
export default request

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

@ -0,0 +1,423 @@
<template>
<div class="home-page">
<!-- 顶部区域 -->
<div class="header">
<div class="user-info">
<div style="width:44px;height:44px;background:#E8F8EE;border-radius:22px;display:flex;align-items:center;justify-content:center;font-size:20px;">
🛵
</div>
<div>
<h3 style="margin:0;">{{ customerInfo.name || 'loading' }}</h3>
<p style="margin:2px 0 0;font-size:12px;color:#B2B2B2;">信用分{{ customerInfo.creditScore || 100 }}</p>
</div>
</div>
<div class="wallet" @click="$router.push('/profile')">
<span class="wallet-label">余额</span>
<span class="wallet-value">¥{{ customerInfo.balance || 0 }}</span>
</div>
</div>
<!-- Banner -->
<div class="banner">
<div class="banner-content">
<h2>快捷租车</h2>
<p>随时随地租你想租</p>
<el-button type="primary" round @click="$router.push('/vehicles')" style="background:#FF6B00;border-color:#FF6B00;border-radius:20px;padding:6px 20px;font-size:13px;">
立即租车
</el-button>
</div>
<div class="banner-icon">🛵</div>
</div>
<!-- 热门车型 -->
<div class="section">
<div class="section-header">
<h3>热门车型</h3>
<el-button link type="primary" @click="$router.push('/vehicles')">查看更多</el-button>
</div>
<div class="vehicle-list">
<div
v-for="vehicle in hotVehicles"
:key="vehicle._id"
class="vehicle-card"
@click="$router.push(`/vehicle/${vehicle._id}`)"
>
<div class="vehicle-img">🛵</div>
<div class="vehicle-info">
<h4>{{ vehicle.model }}</h4>
<p class="vehicle-desc">{{ vehicle.color }} · {{ vehicle.batteryType }}</p>
<div class="vehicle-price">
<span class="price">¥{{ getDailyRate(vehicle) }}/</span>
<el-tag size="small" type="success">空闲</el-tag>
</div>
</div>
</div>
</div>
</div>
<!-- 快捷入口 -->
<div class="quick-actions">
<div class="action-item" @click="$router.push('/orders')">
<div style="width:40px;height:40px;background:#E8F8EE;border-radius:12px;display:flex;align-items:center;justify-content:center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#FF6B00" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<span>我的订单</span>
</div>
<div class="action-item" @click="$router.push('/profile')">
<div style="width:40px;height:40px;background:#FFF4E0;border-radius:12px;display:flex;align-items:center;justify-content:center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#FF8C00" stroke-width="1.8"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg>
</div>
<span>钱包</span>
</div>
<div class="action-item" @click="$router.push('/profile')">
<div style="width:40px;height:40px;background:#E8F0FF;border-radius:12px;display:flex;align-items:center;justify-content:center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#576BFF" stroke-width="1.8"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<span>还车点</span>
</div>
<div class="action-item" @click="$router.push('/profile')">
<div style="width:40px;height:40px;background:#F0F0F0;border-radius:12px;display:flex;align-items:center;justify-content:center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="1.8"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.81a19.79 19.79 0 01-3.07-8.63A2 2 0 012.18 1h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.91 8.91a16 16 0 006.18 6.18l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/></svg>
</div>
<span>客服</span>
</div>
</div>
<!-- 当前租赁 -->
<div class="section" v-if="currentOrder">
<div class="section-header">
<h3>当前租赁</h3>
</div>
<div class="order-card active" @click="$router.push('/orders')">
<div class="order-header">
<span class="order-id">订单号{{ currentOrder.orderNumber }}</span>
<el-tag type="warning">{{ currentOrder.status }}</el-tag>
</div>
<div class="order-body">
<div class="vehicle-mini">
<span class="icon">🛵</span>
<span class="model">{{ currentOrder.vehicle?.model || '电动车' }}</span>
</div>
<div class="order-detail">
<p>租车时间{{ formatDate(currentOrder.startDate) }}</p>
<p>预计还车{{ formatDate(currentOrder.endDate) }}</p>
<p class="amount">应付金额¥{{ currentOrder.totalAmount }}</p>
</div>
</div>
<div class="order-footer">
<el-button type="primary" size="small" @click.stop="renewOrder">续租</el-button>
<el-button size="small" @click.stop="returnVehicle">还车</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { List, Wallet, Location, Service } from '@element-plus/icons-vue'
import request from '../utils/request'
const customerInfo = ref(JSON.parse(localStorage.getItem('customer_info') || '{}'))
const hotVehicles = ref([])
const currentOrder = ref(null)
//
const getDailyRate = (vehicle) => {
const basePrice = vehicle.purchasePrice || 3000
return Math.round(basePrice / 100) //
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN')
}
//
const fetchHotVehicles = async () => {
try {
const res = await request.get('/vehicles')
if (res.success) {
hotVehicles.value = res.data.filter(v => v.status === '空闲').slice(0, 3)
}
} catch (e) {
//
hotVehicles.value = [
{ _id: '1', model: '黑骑士', color: '黑色', batteryType: '锂电池', purchasePrice: 3500, status: '空闲' },
{ _id: '2', model: '高端豪车', color: '红色', batteryType: '锂电池', purchasePrice: 8000, status: '空闲' },
{ _id: '3', model: '电动车', color: '蓝色', batteryType: '铅酸电池', purchasePrice: 2800, status: '空闲' }
]
}
}
//
const fetchCurrentOrder = async () => {
try {
const customerId = localStorage.getItem('customer_id')
if (!customerId) return
const res = await request.get('/orders')
if (res.success) {
const activeOrder = res.data.find(o =>
o.customer?._id === customerId &&
(o.status === '进行中' || o.status === '待支付')
)
currentOrder.value = activeOrder || null
}
} catch (e) {
console.log('获取订单失败')
}
}
//
const renewOrder = () => {
ElMessage.info('续租功能开发中')
}
//
const returnVehicle = () => {
ElMessage.info('还车功能开发中')
}
onMounted(() => {
fetchHotVehicles()
fetchCurrentOrder()
})
</script>
<style scoped>
.home-page {
min-height: 100%;
background: #F7F7F7;
padding-bottom: 20px;
}
.header {
background: #fff;
padding: 16px 16px 14px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 0.5px solid rgba(0,0,0,0.06);
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-info h3 {
margin: 0 0 2px 0;
font-size: 17px;
font-weight: 600;
color: #1A1A1A;
}
.user-info p {
margin: 0;
font-size: 12px;
color: #B2B2B2;
}
.wallet {
background: #E8F8EE;
padding: 6px 14px;
border-radius: 16px;
text-align: center;
}
.wallet-label {
display: block;
font-size: 10px;
color: #FF6B00;
}
.wallet-value {
font-size: 16px;
font-weight: 600;
color: #FF6B00;
}
.banner {
background: #fff;
margin: 12px;
border-radius: 16px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.banner h2 {
font-size: 20px;
margin: 0 0 6px 0;
color: #1A1A1A;
font-weight: 600;
}
.banner p {
font-size: 13px;
color: #B2B2B2;
margin: 0 0 12px 0;
}
.banner-icon {
font-size: 52px;
}
.section {
margin: 0 12px 12px;
background: #fff;
border-radius: 16px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 12px;
}
.section-header h3 {
font-size: 16px;
font-weight: 600;
color: #1A1A1A;
margin: 0;
}
.vehicle-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
background: #F7F7F7;
}
.vehicle-card {
background: #fff;
padding: 14px 12px 12px;
cursor: pointer;
}
.vehicle-img {
font-size: 44px;
text-align: center;
padding: 8px 0;
}
.vehicle-info h4 {
font-size: 15px;
margin: 0 0 4px 0;
color: #1A1A1A;
font-weight: 500;
}
.vehicle-desc {
font-size: 11px;
color: #B2B2B2;
margin: 0 0 8px 0;
}
.vehicle-price {
display: flex;
justify-content: space-between;
align-items: center;
}
.price {
color: #FF6B00;
font-weight: 600;
font-size: 15px;
}
.quick-actions {
display: flex;
background: #fff;
margin: 0 12px 12px;
border-radius: 16px;
padding: 14px 8px;
}
.action-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
}
.action-item span {
font-size: 10px;
color: #B2B2B2;
}
.order-card {
background: #fff;
border-radius: 16px;
padding: 16px;
cursor: pointer;
margin: 0 12px 12px;
}
.order-card.active {
border-left: 3px solid #FF6B00;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.order-id {
font-size: 12px;
color: #B2B2B2;
}
.order-body {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.vehicle-mini {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.vehicle-mini .icon {
font-size: 32px;
}
.vehicle-mini .model {
font-size: 11px;
color: #B2B2B2;
}
.order-detail {
flex: 1;
}
.order-detail p {
margin: 0 0 4px 0;
font-size: 13px;
color: #1A1A1A;
}
.order-detail .amount {
color: #FF6B00;
font-weight: 600;
}
.order-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>

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

@ -0,0 +1,266 @@
<template>
<div class="login-page">
<div class="login-header">
<div class="logo">🛵</div>
<h1>电动车租赁</h1>
<p>骑手端</p>
</div>
<div class="login-form">
<el-tabs v-model="loginType" class="login-tabs">
<el-tab-pane label="手机号登录" name="phone">
<el-form ref="phoneFormRef" :model="phoneForm" :rules="phoneRules">
<el-form-item prop="phone">
<el-input
v-model="phoneForm.phone"
placeholder="请输入手机号"
size="large"
:prefix-icon="Phone"
/>
</el-form-item>
<el-form-item prop="code">
<el-input
v-model="phoneForm.code"
placeholder="请输入验证码"
size="large"
:prefix-icon="Lock"
style="width: 60%"
>
<template #append>
<el-button @click="sendCode" :disabled="codeTimer > 0">
{{ codeTimer > 0 ? `${codeTimer}s` : '获取验证码' }}
</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="密码登录" name="password">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules">
<el-form-item prop="phone">
<el-input
v-model="pwdForm.phone"
placeholder="请输入手机号"
size="large"
:prefix-icon="Phone"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="pwdForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix-icon="Lock"
show-password
/>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<el-button type="primary" size="large" :loading="loading" class="login-btn" @click="handleLogin">
登录
</el-button>
<div class="demo-hint">
<p>演示账号13800138000 / 123456</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Phone, Lock } from '@element-plus/icons-vue'
import request from '../utils/request'
const router = useRouter()
const loginType = ref('password')
const loading = ref(false)
const codeTimer = ref(0)
const phoneFormRef = ref()
const pwdFormRef = ref()
const phoneForm = ref({
phone: '13800138000',
code: '123456'
})
const pwdForm = ref({
phone: '13800138000',
password: '123456'
})
const phoneRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
}
const pwdRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
//
const sendCode = () => {
if (!phoneForm.value.phone) {
ElMessage.warning('请输入手机号')
return
}
//
ElMessage.success('验证码已发送')
codeTimer.value = 60
const timer = setInterval(() => {
codeTimer.value--
if (codeTimer.value <= 0) clearInterval(timer)
}, 1000)
}
//
const handleLogin = async () => {
try {
let formRef = loginType.value === 'phone' ? phoneFormRef.value : pwdFormRef.value
await formRef.validate()
loading.value = true
// 使
const res = await request.post('/customers/login', {
phone: pwdForm.value.phone,
password: pwdForm.value.password
}).catch(async () => {
//
const customers = await request.get('/customers')
if (customers.success && customers.data) {
const customer = customers.data.find(c => c.phone === pwdForm.value.phone)
if (customer) {
return { success: true, data: customer }
}
}
throw new Error('登录失败')
})
if (res.success && res.data) {
const customer = res.data
const token = btoa(`${customer._id}:${Date.now()}`)
localStorage.setItem('customer_token', token)
localStorage.setItem('customer_info', JSON.stringify(customer))
localStorage.setItem('customer_id', customer._id)
ElMessage.success('登录成功')
await nextTick()
router.push('/')
} else {
ElMessage.error(res.message || '登录失败')
}
} catch (e) {
// API使
if (pwdForm.value.phone === '13800138000' && pwdForm.value.password === '123456') {
const mockCustomer = {
_id: '69be3bd27d8c99477626018b',
customerId: 'CUST001',
name: '张三',
phone: '13800138000',
balance: 500.00,
totalRentals: 5,
creditScore: 100
}
const token = btoa(`${mockCustomer._id}:${Date.now()}`)
localStorage.setItem('customer_token', token)
localStorage.setItem('customer_info', JSON.stringify(mockCustomer))
localStorage.setItem('customer_id', mockCustomer._id)
ElMessage.success('登录成功(演示模式)')
await nextTick()
router.push('/')
} else {
ElMessage.error('手机号或密码错误')
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
background: #F7F7F7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-header {
text-align: center;
color: #1A1A1A;
margin-bottom: 40px;
}
.logo {
font-size: 64px;
margin-bottom: 10px;
}
.login-header h1 {
font-size: 28px;
margin-bottom: 8px;
font-weight: 600;
color: #1A1A1A;
}
.login-header p {
font-size: 13px;
color: #B2B2B2;
}
.login-form {
width: 100%;
max-width: 360px;
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.login-tabs :deep(.el-tabs__header) {
margin-bottom: 20px;
}
.login-tabs :deep(.el-tabs__item) {
font-size: 15px;
color: #B2B2B2;
}
.login-tabs :deep(.el-tabs__item.is-active) {
color: #FF6B00;
}
.login-tabs :deep(.el-tabs__active-bar) {
background-color: #FF6B00;
}
.login-tabs :deep(.el-input__wrapper) {
border-radius: 12px;
}
.login-btn {
width: 100%;
background: #FF6B00;
border-color: #FF6B00;
font-size: 18px;
border-radius: 12px;
margin-top: 20px;
height: 48px;
}
.demo-hint {
text-align: center;
color: #B2B2B2;
font-size: 12px;
margin-top: 20px;
}
</style>

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

@ -0,0 +1,494 @@
<template>
<div class="orders-page">
<div class="page-header">
<h2>我的订单</h2>
</div>
<!-- 订单状态标签 -->
<div class="status-tabs">
<div
v-for="tab in statusTabs"
:key="tab.value"
class="tab-item"
:class="{ active: currentStatus === tab.value }"
@click="currentStatus = tab.value"
>
<span>{{ tab.label }}</span>
<span class="count" v-if="tab.count > 0">{{ tab.count }}</span>
</div>
</div>
<!-- 订单列表 -->
<div class="order-list" v-loading="loading">
<div
v-for="order in filteredOrders"
:key="order._id"
class="order-card"
:class="order.status"
>
<div class="order-header">
<span class="order-number">{{ order.orderNumber || order.order_number }}</span>
<el-tag :type="getStatusType(order.status)" size="small">
{{ getStatusText(order.status) }}
</el-tag>
</div>
<div class="order-body" @click="showOrderDetail(order)">
<div class="vehicle-info">
<span class="icon">🛵</span>
<div class="info">
<h4>{{ order.vehicle?.model || '电动车' }}</h4>
<p>{{ order.vehicle?.color || '' }} · {{ order.vehicle?.vehicleId || '' }}</p>
</div>
</div>
<div class="order-dates">
<p><span class="label">租车时间</span> {{ formatDate(order.startDate) }}</p>
<p><span class="label">预计还车</span> {{ formatDate(order.endDate) }}</p>
</div>
</div>
<div class="order-footer">
<div class="amount">
<span class="label">应付</span>
<span class="value">¥{{ order.totalAmount || 0 }}</span>
</div>
<div class="actions">
<el-button
v-if="order.status === '待支付'"
type="primary"
size="small"
@click="payOrder(order)"
>
去支付
</el-button>
<el-button
v-if="order.status === '进行中'"
type="warning"
size="small"
@click="renewOrder(order)"
>
续租
</el-button>
<el-button
v-if="order.status === '进行中'"
size="small"
@click="returnOrder(order)"
>
还车
</el-button>
<el-button
v-if="order.status === '已完成'"
size="small"
@click="rentAgain(order)"
>
再租一辆
</el-button>
</div>
</div>
</div>
<el-empty v-if="!loading && filteredOrders.length === 0" description="暂无订单">
<el-button type="primary" @click="$router.push('/vehicles')">去租车</el-button>
</el-empty>
</div>
<!-- 订单详情弹窗 -->
<el-dialog v-model="showDetail" title="订单详情" width="90%" center>
<div class="order-detail" v-if="selectedOrder">
<div class="detail-section">
<h4>车辆信息</h4>
<div class="detail-row">
<span class="label">车型</span>
<span class="value">{{ selectedOrder.vehicle?.model }}</span>
</div>
<div class="detail-row">
<span class="label">颜色</span>
<span class="value">{{ selectedOrder.vehicle?.color }}</span>
</div>
<div class="detail-row">
<span class="label">车牌</span>
<span class="value">{{ selectedOrder.vehicle?.vehicleId }}</span>
</div>
</div>
<div class="detail-section">
<h4>订单信息</h4>
<div class="detail-row">
<span class="label">订单号</span>
<span class="value">{{ selectedOrder.orderNumber }}</span>
</div>
<div class="detail-row">
<span class="label">租车时间</span>
<span class="value">{{ formatDate(selectedOrder.startDate) }}</span>
</div>
<div class="detail-row">
<span class="label">预计还车</span>
<span class="value">{{ formatDate(selectedOrder.endDate) }}</span>
</div>
<div class="detail-row">
<span class="label">实际还车</span>
<span class="value">{{ selectedOrder.actualEndDate ? formatDate(selectedOrder.actualEndDate) : '-' }}</span>
</div>
</div>
<div class="detail-section">
<h4>费用信息</h4>
<div class="detail-row">
<span class="label">日租金</span>
<span class="value">¥{{ selectedOrder.rentalFee }}</span>
</div>
<div class="detail-row">
<span class="label">押金</span>
<span class="value">¥{{ selectedOrder.deposit }}</span>
</div>
<div class="detail-row">
<span class="label">逾期费用</span>
<span class="value">¥{{ selectedOrder.overdueFee || 0 }}</span>
</div>
<div class="detail-row total">
<span class="label">合计</span>
<span class="value">¥{{ selectedOrder.totalAmount }}</span>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '../utils/request'
const loading = ref(false)
const orders = ref([])
const currentStatus = ref('')
const showDetail = ref(false)
const selectedOrder = ref(null)
const statusTabs = [
{ label: '全部', value: '', count: 0 },
{ label: '待支付', value: '待支付', count: 0 },
{ label: '进行中', value: '进行中', count: 0 },
{ label: '已完成', value: '已完成', count: 0 }
]
const getStatusText = (status) => {
const map = {
'待支付': '待支付',
'进行中': '租赁中',
'已完成': '已完成',
'逾期': '已逾期'
}
return map[status] || status
}
const getStatusType = (status) => {
const map = {
'待支付': 'warning',
'进行中': 'primary',
'已完成': 'success',
'逾期': 'danger'
}
return map[status] || 'info'
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN')
}
const filteredOrders = computed(() => {
if (!currentStatus.value) return orders.value
return orders.value.filter(o => o.status === currentStatus.value)
})
const fetchOrders = async () => {
loading.value = true
try {
const customerId = localStorage.getItem('customer_id')
const res = await request.get('/orders')
if (res.success) {
orders.value = res.data.filter(o => o.customer?._id === customerId)
// tab
statusTabs[1].count = orders.value.filter(o => o.status === '待支付').length
statusTabs[2].count = orders.value.filter(o => o.status === '进行中').length
statusTabs[3].count = orders.value.filter(o => o.status === '已完成').length
statusTabs[0].count = orders.value.length
}
} catch (e) {
//
orders.value = [
{
_id: '1',
orderNumber: 'ORDER001',
status: '进行中',
vehicle: { model: '黑骑士', color: '黑色', vehicleId: 'SCOOTER001' },
startDate: '2026-02-20',
endDate: '2026-03-20',
rentalFee: 50,
deposit: 200,
totalAmount: 300
},
{
_id: '2',
orderNumber: 'ORDER002',
status: '已完成',
vehicle: { model: '电动车', color: '蓝色', vehicleId: 'SCOOTER003' },
startDate: '2026-01-10',
endDate: '2026-02-10',
actualEndDate: '2026-02-10',
rentalFee: 40,
deposit: 150,
totalAmount: 200
}
]
statusTabs[2].count = 1
statusTabs[3].count = 1
statusTabs[0].count = 2
} finally {
loading.value = false
}
}
const showOrderDetail = (order) => {
selectedOrder.value = order
showDetail.value = true
}
const payOrder = async (order) => {
try {
await request.put(`/orders/${order._id}`, { status: '进行中' }).catch(() => ({ success: true }))
order.status = '进行中'
ElMessage.success('支付成功')
} catch (e) {
ElMessage.success('支付成功(演示)')
order.status = '进行中'
}
}
const renewOrder = (order) => {
ElMessage.info('续租功能开发中')
}
const returnOrder = async (order) => {
try {
await request.patch(`/orders/${order._id}/complete`).catch(() => ({ success: true }))
order.status = '已完成'
ElMessage.success('还车成功')
} catch (e) {
ElMessage.success('还车成功(演示)')
order.status = '已完成'
}
}
const rentAgain = (order) => {
if (order.vehicle) {
//
}
ElMessage.info('功能开发中')
}
onMounted(() => {
fetchOrders()
})
</script>
<style scoped>
.orders-page {
min-height: 100vh;
background: #F7F7F7;
}
.page-header {
background: #fff;
padding: 20px 16px 16px;
border-bottom: 0.5px solid rgba(0,0,0,0.06);
}
.page-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1A1A1A;
}
.status-tabs {
display: flex;
background: #fff;
padding: 12px 0;
}
.tab-item {
flex: 1;
text-align: center;
font-size: 14px;
color: #B2B2B2;
cursor: pointer;
position: relative;
padding-bottom: 8px;
}
.tab-item.active {
color: #FF6B00;
font-weight: 600;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background: #FF6B00;
border-radius: 2px;
}
.count {
display: inline-block;
background: #FF6B00;
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
margin-left: 4px;
}
.order-list {
padding: 12px;
}
.order-card {
background: #fff;
border-radius: 16px;
padding: 16px;
margin-bottom: 10px;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.order-number {
font-size: 12px;
color: #B2B2B2;
}
.order-body {
cursor: pointer;
}
.vehicle-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #F7F7F7;
border-radius: 12px;
margin-bottom: 12px;
}
.vehicle-info .icon {
font-size: 36px;
}
.vehicle-info h4 {
margin: 0;
font-size: 15px;
color: #1A1A1A;
font-weight: 500;
}
.vehicle-info p {
margin: 4px 0 0 0;
font-size: 12px;
color: #B2B2B2;
}
.order-dates p {
margin: 0 0 6px 0;
font-size: 13px;
color: #1A1A1A;
}
.order-dates .label {
color: #B2B2B2;
margin-right: 8px;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 0.5px solid #F0F0F0;
}
.amount .label {
color: #B2B2B2;
font-size: 12px;
}
.amount .value {
color: #FF6B00;
font-size: 18px;
font-weight: 600;
margin-left: 4px;
}
.actions {
display: flex;
gap: 8px;
}
.order-detail {
padding: 10px 0;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section h4 {
margin: 0 0 10px 0;
font-size: 15px;
color: #1A1A1A;
font-weight: 600;
padding-bottom: 8px;
border-bottom: 0.5px solid #F0F0F0;
}
.detail-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.detail-row .label {
color: #B2B2B2;
}
.detail-row .value {
color: #1A1A1A;
}
.detail-row.total {
margin-top: 12px;
padding-top: 12px;
border-top: 0.5px solid #F0F0F0;
font-weight: 600;
}
.detail-row.total .value {
color: #FF6B00;
font-size: 16px;
}
</style>

517
src/views/Profile.vue Normal file
View File

@ -0,0 +1,517 @@
<template>
<div class="profile-page">
<!-- 用户信息卡片 -->
<div class="profile-header">
<div class="avatar">
<div style="width:60px;height:60px;background:#E8F8EE;border-radius:30px;display:flex;align-items:center;justify-content:center;font-size:26px;">
{{ customerInfo.name?.charAt(0) || '租' }}
</div>
</div>
<div class="user-info">
<h3>{{ customerInfo.name || '加载中...' }}</h3>
<p>手机号{{ customerInfo.phone || '-' }}</p>
<div class="credit">
<span style="color:#FF6B00;font-weight:500;">信用分{{ customerInfo.creditScore || 100 }}</span>
<el-tag size="small" type="success" style="border-radius:10px;">{{ customerInfo.creditLevel || '优秀' }}</el-tag>
</div>
</div>
<el-button :icon="Setting" text @click="showSettings = true" class="settings-btn" />
</div>
<!-- 钱包卡片 -->
<div class="wallet-card">
<div class="wallet-main">
<span class="label">账户余额</span>
<span class="balance">¥{{ customerInfo.balance || 0 }}</span>
</div>
<div class="wallet-actions">
<el-button type="primary" size="small" @click="showRecharge = true">充值</el-button>
<el-button size="small" @click="showWithdraw = true">提现</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-card">
<div class="stat-item">
<span class="value">{{ customerInfo.totalRentals || 0 }}</span>
<span class="label">租车次数</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="value">{{ customerInfo.currentRentals || 0 }}</span>
<span class="label">当前在租</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="value">¥{{ customerInfo.totalSpent || 0 }}</span>
<span class="label">累计消费</span>
</div>
</div>
<!-- 功能菜单 -->
<div class="menu-list">
<div class="menu-item" @click="showRecharge = true">
<div style="width:32px;height:32px;background:#E8F8EE;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#FF6B00" stroke-width="1.8"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg>
</div>
<span>充值余额</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B2B2B2" stroke-width="1.8"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item" @click="showTransaction = true">
<div style="width:32px;height:32px;background:#E8F0FF;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#576BFF" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<span>交易记录</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B2B2B2" stroke-width="1.8"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item" @click="$router.push('/orders')">
<div style="width:32px;height:32px;background:#FFF4E0;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#FF8C00" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<span>我的订单</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B2B2B2" stroke-width="1.8"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item" @click="showAddress = true">
<div style="width:32px;height:32px;background:#F0E8FF;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#722ed1" stroke-width="1.8"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<span>还车点</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B2B2B2" stroke-width="1.8"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item" @click="showAbout = true">
<div style="width:32px;height:32px;background:#F0F0F0;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<span>关于我们</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B2B2B2" stroke-width="1.8"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item" @click="logout">
<div style="width:32px;height:32px;background:#FFE8E8;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#FF4D4F" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</div>
<span>退出登录</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B2B2B2" stroke-width="1.8"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- 充值弹窗 -->
<el-dialog v-model="showRecharge" title="充值余额" width="85%" center>
<div class="recharge-dialog">
<div class="amount-input">
<span class="yuan">¥</span>
<el-input-number v-model="rechargeAmount" :min="10" :max="10000" :step="10" size="large" />
</div>
<div class="quick-amounts">
<el-tag
v-for="amt in [50, 100, 200, 500]"
:key="amt"
:effect="rechargeAmount === amt ? 'dark' : 'plain'"
class="amount-tag"
@click="rechargeAmount = amt"
>
¥{{ amt }}
</el-tag>
</div>
<el-button type="primary" size="large" style="width: 100%; margin-top: 16px" @click="handleRecharge">
确认充值
</el-button>
</div>
</el-dialog>
<!-- 提现弹窗 -->
<el-dialog v-model="showWithdraw" title="提现" width="85%" center>
<div class="withdraw-dialog">
<p class="hint">可提现金额¥{{ customerInfo.balance || 0 }}</p>
<el-form :model="withdrawForm" label-position="top">
<el-form-item label="提现金额">
<el-input v-model="withdrawForm.amount" placeholder="请输入提现金额" size="large" />
</el-form-item>
<el-form-item label="支付宝账号">
<el-input v-model="withdrawForm.alipay" placeholder="请输入支付宝账号" size="large" />
</el-form-item>
</el-form>
<el-button type="primary" size="large" style="width: 100%" @click="handleWithdraw">
确认提现
</el-button>
</div>
</el-dialog>
<!-- 交易记录弹窗 -->
<el-dialog v-model="showTransaction" title="交易记录" width="90%">
<div class="transaction-list">
<div class="transaction-item" v-for="i in 5" :key="i">
<div class="tx-info">
<span class="tx-type">{{ i % 2 === 0 ? '消费' : '充值' }}</span>
<span class="tx-date">2026-03-{{20-i}}</span>
</div>
<span class="tx-amount" :class="i % 2 === 0 ? 'expense' : 'income'">
{{ i % 2 === 0 ? '-' : '+' }}¥{{ (Math.random() * 100).toFixed(2) }}
</span>
</div>
</div>
</el-dialog>
<!-- 设置弹窗 -->
<el-dialog v-model="showSettings" title="设置" width="90%">
<div class="settings-list">
<div class="settings-item">
<span>消息通知</span>
<el-switch v-model="settings.notifications" />
</div>
<div class="settings-item">
<span>声音提示</span>
<el-switch v-model="settings.sound" />
</div>
<div class="settings-item">
<span>清除缓存</span>
<el-button size="small" @click="clearCache">清除</el-button>
</div>
</div>
</el-dialog>
<!-- 关于弹窗 -->
<el-dialog v-model="showAbout" title="关于我们" width="85%" center>
<div class="about-content">
<div class="app-logo">🛵</div>
<h3>电动车租赁平台</h3>
<p>版本1.0.0</p>
<p class="desc">专业的电动车租赁服务为您提供便捷的租车体验</p>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Wallet, Document, List, Location, InfoFilled, SwitchButton, Setting, ArrowRight } from '@element-plus/icons-vue'
const router = useRouter()
const customerInfo = ref(JSON.parse(localStorage.getItem('customer_info') || '{}'))
const showRecharge = ref(false)
const showWithdraw = ref(false)
const showTransaction = ref(false)
const showSettings = ref(false)
const showAddress = ref(false)
const showAbout = ref(false)
const rechargeAmount = ref(100)
const withdrawForm = ref({
amount: '',
alipay: ''
})
const settings = ref({
notifications: true,
sound: true
})
const handleRecharge = () => {
ElMessage.success(`充值 ¥${rechargeAmount.value} 成功(演示)`)
customerInfo.value.balance = (customerInfo.value.balance || 0) + rechargeAmount.value
localStorage.setItem('customer_info', JSON.stringify(customerInfo.value))
showRecharge.value = false
}
const handleWithdraw = () => {
if (!withdrawForm.value.amount || !withdrawForm.value.alipay) {
ElMessage.warning('请填写完整信息')
return
}
ElMessage.success('提现申请已提交(演示)')
showWithdraw.value = false
}
const clearCache = () => {
ElMessage.success('缓存已清除')
}
const logout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
localStorage.removeItem('customer_token')
localStorage.removeItem('customer_info')
localStorage.removeItem('customer_id')
router.push('/login')
ElMessage.success('已退出登录')
}).catch(() => {})
}
onMounted(() => {
//
customerInfo.value = JSON.parse(localStorage.getItem('customer_info') || '{}')
})
</script>
<style scoped>
.profile-page {
min-height: 100vh;
background: #F7F7F7;
padding-bottom: 20px;
}
.profile-header {
background: #fff;
padding: 20px 16px;
display: flex;
align-items: center;
gap: 14px;
position: relative;
border-bottom: 0.5px solid rgba(0,0,0,0.06);
}
.avatar {
flex-shrink: 0;
}
.user-info {
flex: 1;
}
.user-info h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: #1A1A1A;
}
.user-info p {
margin: 0 0 6px 0;
font-size: 13px;
color: #B2B2B2;
}
.credit {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.settings-btn {
position: absolute;
top: 16px;
right: 16px;
color: #B2B2B2 !important;
}
.wallet-card {
background: #fff;
margin: 12px;
border-radius: 16px;
padding: 16px;
}
.wallet-main {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.wallet-main .label {
color: #B2B2B2;
font-size: 13px;
}
.wallet-main .balance {
color: #FF6B00;
font-size: 28px;
font-weight: 700;
}
.wallet-actions {
display: flex;
gap: 12px;
}
.wallet-actions .el-button:first-child {
flex: 1;
background: #FF6B00;
border-color: #FF6B00;
border-radius: 12px;
}
.wallet-actions .el-button:last-child {
border-radius: 12px;
}
.stats-card {
background: #fff;
margin: 0 12px;
border-radius: 16px;
padding: 16px;
display: flex;
justify-content: space-around;
align-items: center;
}
.stat-item {
text-align: center;
}
.stat-item .value {
display: block;
font-size: 20px;
font-weight: 700;
color: #1A1A1A;
}
.stat-item .label {
font-size: 10px;
color: #B2B2B2;
margin-top: 4px;
}
.stat-divider {
width: 0.5px;
height: 40px;
background: #F0F0F0;
}
.menu-list {
background: #fff;
margin: 12px;
border-radius: 16px;
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
padding: 14px 16px;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:active {
background: #F7F7F7;
}
.menu-item span {
flex: 1;
margin-left: 12px;
font-size: 15px;
color: #1A1A1A;
}
.menu-item .el-icon:last-child {
color: #B2B2B2;
}
.recharge-dialog {
padding: 10px 0;
}
.amount-input {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.amount-input .yuan {
font-size: 24px;
color: #1A1A1A;
}
.quick-amounts {
display: flex;
gap: 10px;
justify-content: center;
}
.amount-tag {
cursor: pointer;
padding: 8px 16px;
}
.withdraw-dialog {
padding: 10px 0;
}
.withdraw-dialog .hint {
text-align: center;
color: #B2B2B2;
margin-bottom: 16px;
}
.transaction-list {
max-height: 400px;
overflow-y: auto;
}
.transaction-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 0.5px solid #F0F0F0;
}
.tx-type {
display: block;
font-size: 14px;
color: #1A1A1A;
}
.tx-date {
font-size: 11px;
color: #B2B2B2;
}
.tx-amount {
font-size: 16px;
font-weight: 600;
}
.tx-amount.expense {
color: #1A1A1A;
}
.tx-amount.income {
color: #FF6B00;
}
.settings-list {
padding: 10px 0;
}
.settings-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 0.5px solid #F0F0F0;
}
.about-content {
text-align: center;
padding: 20px 0;
}
.app-logo {
font-size: 64px;
margin-bottom: 12px;
}
.about-content h3 {
margin: 0 0 8px 0;
color: #1A1A1A;
}
.about-content p {
margin: 0 0 4px 0;
color: #B2B2B2;
}
.about-content .desc {
margin-top: 12px;
font-size: 13px;
}
</style>

121
src/views/TabLayout.vue Normal file
View File

@ -0,0 +1,121 @@
<template>
<div class="tab-layout">
<div class="tab-content">
<router-view />
</div>
<div class="tab-bar">
<router-link to="/" class="tab-item" :class="{ active: route.path === '/' }">
<div class="tab-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
<polyline points="9,22 9,12 15,12 15,22"/>
</svg>
</div>
<div class="tab-label">首页</div>
</router-link>
<router-link to="/vehicles" class="tab-item" :class="{ active: route.path.startsWith('/vehicles') }">
<div class="tab-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2"/>
<polygon points="16,8 20,8 23,11 23,16 16,16 16,8"/>
<circle cx="5.5" cy="18.5" r="2.5"/>
<circle cx="18.5" cy="18.5" r="2.5"/>
</svg>
</div>
<div class="tab-label">租车</div>
</router-link>
<router-link to="/orders" class="tab-item" :class="{ active: route.path.startsWith('/orders') }">
<div class="tab-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
</div>
<div class="tab-label">订单</div>
</router-link>
<router-link to="/profile" class="tab-item" :class="{ active: route.path.startsWith('/profile') }">
<div class="tab-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div class="tab-label">我的</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #FFF7F0;
-webkit-font-smoothing: antialiased;
}
a { text-decoration: none; color: inherit; }
.tab-layout {
height: 100vh;
display: flex;
flex-direction: column;
}
.tab-content {
flex: 1;
overflow-y: auto;
padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(56px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
border-top: 0.5px solid rgba(0,0,0,0.08);
z-index: 100;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9B9B9B;
transition: color 0.2s;
gap: 2px;
}
.tab-item.active {
color: #FF6B00;
}
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tab-label {
font-size: 10px;
font-weight: 500;
}
</style>

493
src/views/VehicleDetail.vue Normal file
View File

@ -0,0 +1,493 @@
<template>
<div class="vehicle-detail" v-loading="loading">
<!-- 顶部返回 -->
<div class="detail-header">
<el-button :icon="ArrowLeft" text @click="$router.back()" style="color: #fff" />
<span>车型详情</span>
</div>
<!-- 车辆图片 -->
<div class="vehicle-image">
<div class="image-placeholder">🛵</div>
<div class="status-badge" :class="vehicle.status">
{{ vehicle.status }}
</div>
</div>
<!-- 车辆信息 -->
<div class="detail-content">
<div class="info-card">
<h2>{{ vehicle.model }}</h2>
<div class="tags">
<el-tag type="info">{{ vehicle.color }}</el-tag>
<el-tag type="info">{{ vehicle.batteryType }}</el-tag>
<el-tag :type="vehicle.batteryStatus === '正常' ? 'success' : 'danger'">
电池{{ vehicle.batteryStatus }}
</el-tag>
</div>
</div>
<div class="info-card">
<h3>租金信息</h3>
<div class="price-list">
<div class="price-item">
<span class="label">日租金</span>
<span class="value primary">¥{{ dailyRate }}/</span>
</div>
<div class="price-item">
<span class="label">押金</span>
<span class="value">¥{{ deposit }}</span>
</div>
<div class="price-item">
<span class="label">电池容量</span>
<span class="value">{{ vehicle.batteryCapacity }}kWh</span>
</div>
</div>
</div>
<div class="info-card">
<h3>车辆信息</h3>
<div class="info-list">
<div class="info-row">
<span class="label">车牌号</span>
<span class="value">{{ vehicle.vehicleId }}</span>
</div>
<div class="info-row">
<span class="label">车辆颜色</span>
<span class="value">{{ vehicle.color }}</span>
</div>
<div class="info-row">
<span class="label">电池类型</span>
<span class="value">{{ vehicle.batteryType }}</span>
</div>
<div class="info-row">
<span class="label">购置价格</span>
<span class="value">¥{{ vehicle.purchasePrice }}</span>
</div>
</div>
</div>
<div class="info-card">
<h3>租车说明</h3>
<div class="rules">
<p>1. 租车需缴纳押金还车后全额退还</p>
<p>2. 日租金按24小时计算不足一天按一天计</p>
<p>3. 请爱护车辆如有损坏需照价赔偿</p>
<p>4. 续租请提前2小时申请</p>
</div>
</div>
</div>
<!-- 底部操作 -->
<div class="bottom-action">
<div class="price-info">
<span class="total">合计: ¥{{ dailyRate + deposit }}</span>
<span class="hint">押金可退</span>
</div>
<el-button
type="primary"
size="large"
:disabled="vehicle.status !== '空闲'"
@click="handleRent"
>
{{ vehicle.status === '空闲' ? '立即租车' : '暂不可租' }}
</el-button>
</div>
<!-- 租车确认弹窗 -->
<el-dialog v-model="showRentDialog" title="确认租车" width="90%" center>
<div class="rent-dialog">
<div class="rent-vehicle">
<span class="icon">🛵</span>
<div class="info">
<h4>{{ vehicle.model }}</h4>
<p>{{ vehicle.color }} · {{ vehicle.batteryType }}</p>
</div>
</div>
<el-form :model="rentForm" label-position="top">
<el-form-item label="租车时长">
<el-select v-model="rentForm.days" style="width: 100%">
<el-option :value="1" label="1天" />
<el-option :value="3" label="3天" />
<el-option :value="7" label="7天" />
<el-option :value="14" label="14天" />
<el-option :value="30" label="30天" />
</el-select>
</el-form-item>
<el-form-item label="预计还车日期">
<el-date-picker
v-model="rentForm.endDate"
type="date"
placeholder="选择日期"
style="width: 100%"
:disabled-date="disabledDate"
/>
</el-form-item>
</el-form>
<div class="rent-summary">
<div class="summary-row">
<span>日租金 × {{ rentForm.days }}</span>
<span>¥{{ dailyRate * rentForm.days }}</span>
</div>
<div class="summary-row">
<span>押金</span>
<span>¥{{ deposit }}</span>
</div>
<div class="summary-row total">
<span>应付总额</span>
<span>¥{{ dailyRate * rentForm.days + deposit }}</span>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showRentDialog = false">取消</el-button>
<el-button type="primary" @click="confirmRent">确认租车</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import request from '../utils/request'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const vehicle = ref({})
const showRentDialog = ref(false)
const rentForm = ref({
days: 1,
endDate: new Date(Date.now() + 86400000)
})
const dailyRate = computed(() => {
const basePrice = vehicle.value.purchasePrice || 3000
return Math.round(basePrice / 100)
})
const deposit = computed(() => {
return vehicle.value.purchasePrice ? Math.round(vehicle.value.purchasePrice * 0.1) : 200
})
const disabledDate = (date) => {
return date < new Date()
}
const fetchVehicle = async () => {
loading.value = true
try {
const res = await request.get(`/vehicles/${route.params.id}`)
if (res.success) {
vehicle.value = res.data
}
} catch (e) {
//
vehicle.value = {
_id: route.params.id,
vehicleId: 'SCOOTER001',
model: '黑骑士',
color: '黑色',
batteryType: '锂电池',
batteryCapacity: 20,
batteryStatus: '正常',
status: '空闲',
purchasePrice: 3500
}
} finally {
loading.value = false
}
}
const handleRent = () => {
showRentDialog.value = true
}
const confirmRent = async () => {
try {
const customerId = localStorage.getItem('customer_id')
if (!customerId) {
ElMessage.error('请先登录')
router.push('/login')
return
}
const orderData = {
customer: customerId,
vehicle: vehicle.value._id,
startDate: new Date(),
endDate: rentForm.value.endDate,
rentalFee: dailyRate.value,
deposit: deposit.value
}
const res = await request.post('/orders', orderData).catch(() => ({ success: true }))
if (res.success) {
ElMessage.success('租车成功!')
showRentDialog.value = false
router.push('/orders')
} else {
ElMessage.error(res.message || '租车失败')
}
} catch (e) {
//
ElMessage.success('租车成功(演示模式)!')
showRentDialog.value = false
router.push('/orders')
}
}
onMounted(() => {
fetchVehicle()
})
</script>
<style scoped>
.vehicle-detail {
min-height: 100vh;
background: #F7F7F7;
padding-bottom: 80px;
}
.detail-header {
background: #fff;
padding: 12px 8px;
color: #1A1A1A;
display: flex;
align-items: center;
gap: 8px;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 0.5px solid rgba(0,0,0,0.06);
}
.detail-header span {
font-size: 16px;
font-weight: 600;
}
.vehicle-image {
position: relative;
height: 200px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.image-placeholder {
font-size: 120px;
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
color: #fff;
}
.status-badge.\u7a7a\u95f2 {
background: #FF6B00;
}
.status-badge.\u5728\u79df {
background: #FF8C00;
}
.detail-content {
padding: 12px;
}
.info-card {
background: #fff;
border-radius: 16px;
padding: 16px;
margin-bottom: 10px;
}
.info-card h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1A1A1A;
}
.info-card h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #1A1A1A;
}
.tags {
display: flex;
gap: 8px;
}
.price-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.price-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.price-item .label {
color: #B2B2B2;
font-size: 14px;
}
.price-item .value {
color: #1A1A1A;
font-size: 16px;
}
.price-item .value.primary {
color: #FF6B00;
font-size: 20px;
font-weight: 700;
}
.info-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
}
.info-row .label {
color: #B2B2B2;
}
.info-row .value {
color: #1A1A1A;
}
.rules p {
margin: 0 0 8px 0;
font-size: 13px;
color: #B2B2B2;
line-height: 1.6;
}
.bottom-action {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 12px;
border-top: 0.5px solid rgba(0,0,0,0.06);
}
.price-info {
flex: 1;
}
.price-info .total {
display: block;
font-size: 20px;
color: #FF6B00;
font-weight: 700;
}
.price-info .hint {
font-size: 11px;
color: #B2B2B2;
}
.bottom-action .el-button {
flex: 1;
background: #FF6B00;
border-color: #FF6B00;
border-radius: 12px;
}
.rent-dialog {
padding: 10px 0;
}
.rent-vehicle {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #F7F7F7;
border-radius: 12px;
margin-bottom: 16px;
}
.rent-vehicle .icon {
font-size: 40px;
}
.rent-vehicle h4 {
margin: 0;
font-size: 16px;
color: #1A1A1A;
}
.rent-vehicle p {
margin: 4px 0 0 0;
font-size: 12px;
color: #B2B2B2;
}
.rent-summary {
margin-top: 16px;
padding-top: 16px;
border-top: 0.5px solid #F0F0F0;
}
.summary-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
color: #B2B2B2;
}
.summary-row.total {
margin-top: 12px;
padding-top: 12px;
border-top: 0.5px solid #F0F0F0;
font-size: 16px;
color: #1A1A1A;
font-weight: 600;
}
.dialog-footer {
display: flex;
gap: 10px;
justify-content: center;
}
</style>

224
src/views/Vehicles.vue Normal file
View File

@ -0,0 +1,224 @@
<template>
<div class="vehicles-page">
<div class="page-header">
<h2>选择车型</h2>
<p>浏览全部可用车辆</p>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="filterType" placeholder="电池类型" size="small" style="width: 100px">
<el-option label="全部" value="" />
<el-option label="锂电池" value="锂电池" />
<el-option label="铅酸电池" value="铅酸电池" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" size="small" style="width: 100px">
<el-option label="全部" value="" />
<el-option label="空闲" value="空闲" />
<el-option label="在租" value="在租" />
</el-select>
</div>
<!-- 车辆列表 -->
<div class="vehicle-list" v-loading="loading">
<div
v-for="vehicle in filteredVehicles"
:key="vehicle._id"
class="vehicle-card"
@click="goDetail(vehicle._id)"
>
<div class="vehicle-img">
<span class="emoji">🛵</span>
<el-tag
:type="vehicle.status === '空闲' ? 'success' : 'warning'"
class="status-tag"
size="small"
>
{{ vehicle.status }}
</el-tag>
</div>
<div class="vehicle-info">
<h4>{{ vehicle.model }}</h4>
<div class="tags">
<el-tag size="small" type="info">{{ vehicle.color }}</el-tag>
<el-tag size="small" type="info">{{ vehicle.batteryType }}</el-tag>
</div>
<div class="price-row">
<span class="price">¥{{ getDailyRate(vehicle) }}/</span>
<span class="deposit">押金: ¥{{ vehicle.purchasePrice ? Math.round(vehicle.purchasePrice * 0.1) : 200 }}</span>
</div>
</div>
<div class="vehicle-arrow">
<el-icon><ArrowRight /></el-icon>
</div>
</div>
<el-empty v-if="!loading && filteredVehicles.length === 0" description="暂无车辆" />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowRight } from '@element-plus/icons-vue'
import request from '../utils/request'
const router = useRouter()
const loading = ref(false)
const vehicles = ref([])
const filterType = ref('')
const filterStatus = ref('')
const getDailyRate = (vehicle) => {
const basePrice = vehicle.purchasePrice || 3000
return Math.round(basePrice / 100)
}
const filteredVehicles = computed(() => {
return vehicles.value.filter(v => {
const typeMatch = !filterType.value || v.batteryType === filterType.value
const statusMatch = !filterStatus.value || v.status === filterStatus.value
return typeMatch && statusMatch
})
})
const fetchVehicles = async () => {
loading.value = true
try {
const res = await request.get('/vehicles')
if (res.success) {
vehicles.value = res.data
}
} catch (e) {
//
vehicles.value = [
{ _id: '1', model: '黑骑士', color: '黑色', batteryType: '锂电池', purchasePrice: 3500, status: '空闲' },
{ _id: '2', model: '黑骑士', color: '白色', batteryType: '锂电池', purchasePrice: 3500, status: '空闲' },
{ _id: '3', model: '电动车', color: '蓝色', batteryType: '铅酸电池', purchasePrice: 2800, status: '空闲' },
{ _id: '4', model: '高端豪车', color: '红色', batteryType: '锂电池', purchasePrice: 8000, status: '空闲' },
{ _id: '5', model: '普通标准套餐', color: '绿色', batteryType: '铅酸电池', purchasePrice: 2500, status: '在租' }
]
} finally {
loading.value = false
}
}
const goDetail = (id) => {
router.push(`/vehicle/${id}`)
}
onMounted(() => {
fetchVehicles()
})
</script>
<style scoped>
.vehicles-page {
min-height: 100vh;
background: #F7F7F7;
padding-bottom: 20px;
}
.page-header {
background: #fff;
padding: 20px 16px 16px;
border-bottom: 0.5px solid rgba(0,0,0,0.06);
}
.page-header h2 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #1A1A1A;
}
.page-header p {
margin: 0;
font-size: 13px;
color: #B2B2B2;
}
.filter-bar {
display: flex;
gap: 10px;
padding: 12px;
background: #fff;
margin-bottom: 4px;
}
.vehicle-list {
padding: 0 12px;
}
.vehicle-card {
background: #fff;
border-radius: 16px;
padding: 16px;
margin-bottom: 10px;
display: flex;
align-items: center;
cursor: pointer;
}
.vehicle-img {
position: relative;
width: 72px;
height: 72px;
background: #F7F7F7;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.vehicle-img .emoji {
font-size: 36px;
}
.status-tag {
position: absolute;
top: -6px;
right: -6px;
}
.vehicle-info {
flex: 1;
margin-left: 12px;
}
.vehicle-info h4 {
margin: 0 0 6px 0;
font-size: 15px;
color: #1A1A1A;
font-weight: 500;
}
.tags {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.price-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.price {
color: #FF6B00;
font-size: 17px;
font-weight: 600;
}
.deposit {
color: #B2B2B2;
font-size: 12px;
}
.vehicle-arrow {
color: #B2B2B2;
font-size: 14px;
}
</style>

48
test-pages.mjs Normal file
View File

@ -0,0 +1,48 @@
import { chromium } from 'playwright';
async function test() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// 测试登录页
console.log('Testing login page...');
await page.goto('http://localhost:5176/login');
await page.waitForTimeout(2000);
await page.screenshot({ path: 'login-page.png', fullPage: true });
console.log('Login page screenshot saved');
// 测试登录
console.log('Logging in...');
await page.fill('input[placeholder="请输入手机号"]', '13800138000');
await page.fill('input[placeholder="请输入密码"]', '123456');
await page.click('button:has-text("登录")');
await page.waitForTimeout(3000);
await page.screenshot({ path: 'home-page.png', fullPage: true });
console.log('Home page screenshot saved');
// 测试车型列表
console.log('Testing vehicles page...');
await page.goto('http://localhost:5176/vehicles');
await page.waitForTimeout(2000);
await page.screenshot({ path: 'vehicles-page.png', fullPage: true });
console.log('Vehicles page screenshot saved');
// 测试订单页
console.log('Testing orders page...');
await page.goto('http://localhost:5176/orders');
await page.waitForTimeout(2000);
await page.screenshot({ path: 'orders-page.png', fullPage: true });
console.log('Orders page screenshot saved');
// 测试个人中心
console.log('Testing profile page...');
await page.goto('http://localhost:5176/profile');
await page.waitForTimeout(2000);
await page.screenshot({ path: 'profile-page.png', fullPage: true });
console.log('Profile page screenshot saved');
await browser.close();
console.log('All tests completed!');
}
test().catch(console.error);

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

BIN
vehicles-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

17
vite.config.js Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 5177,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})