Ver Fonte

1,update

cloud há 1 semana atrás
pai
commit
0018bae2b7
52 ficheiros alterados com 221 adições e 53 exclusões
  1. 2 0
      server/docs/WATER_MINI_API.md
  2. 1 0
      server/public/water-mini/assets/BottomNav.DMrVRe7J.js
  3. 0 0
      server/public/water-mini/assets/CommandStatus.vue_vue_type_script_setup_true_lang.BjITQA99.js
  4. 0 0
      server/public/water-mini/assets/DevicePageHeader.BSYDrn_l.js
  5. 1 0
      server/public/water-mini/assets/IdentityGuard.CRrlQvEl.js
  6. 0 0
      server/public/water-mini/assets/PackageCard.o8o-Y5Md.js
  7. 1 0
      server/public/water-mini/assets/PageState.Czlpu_Kn.js
  8. 1 0
      server/public/water-mini/assets/PrivatePhotos.rphLut-F.js
  9. 1 0
      server/public/water-mini/assets/QuantityInput.D0eRj7qp.js
  10. 1 0
      server/public/water-mini/assets/TaskCard.vue_vue_type_script_setup_true_lang.BQCYiRye.js
  11. 1 0
      server/public/water-mini/assets/cart.BmrEUm50.js
  12. 1 0
      server/public/water-mini/assets/format.Nrx3LY5T.js
  13. 2 0
      server/public/water-mini/assets/index-B8jJyCMw.js
  14. 1 0
      server/public/water-mini/assets/location.BzU4PAth.js
  15. 0 0
      server/public/water-mini/assets/login-Cel95xNG.css
  16. 1 0
      server/public/water-mini/assets/pages-account-identities.BFPFYYjG.js
  17. 0 0
      server/public/water-mini/assets/pages-account-login.DxUd7oTQ.js
  18. 0 0
      server/public/water-mini/assets/pages-account-profile.CYhkfqgS.js
  19. 0 0
      server/public/water-mini/assets/pages-account-rewards.D8Gb4W8E.js
  20. 0 0
      server/public/water-mini/assets/pages-addresses-edit.BpGo-jw3.js
  21. 0 0
      server/public/water-mini/assets/pages-addresses-index.BUcClaJP.js
  22. 0 0
      server/public/water-mini/assets/pages-agent-commissions.CqrzaVTY.js
  23. 0 0
      server/public/water-mini/assets/pages-agent-customers.Dwac9rXw.js
  24. 0 0
      server/public/water-mini/assets/pages-agent-dashboard.BHD7JxGx.js
  25. 1 0
      server/public/water-mini/assets/pages-agent-orders.BanYtBXs.js
  26. 0 0
      server/public/water-mini/assets/pages-common-map.CskUSDOy.js
  27. 1 0
      server/public/water-mini/assets/pages-common-web.CJMHF_mv.js
  28. 1 0
      server/public/water-mini/assets/pages-devices-detail.D1IVPWUZ.js
  29. 0 0
      server/public/water-mini/assets/pages-devices-index.D8cXtdjL.js
  30. 0 0
      server/public/water-mini/assets/pages-driver-dashboard.C91gVQiE.js
  31. 0 0
      server/public/water-mini/assets/pages-driver-device.Cc_txlLK.js
  32. 0 0
      server/public/water-mini/assets/pages-driver-fulfill.jxyb4RsP.js
  33. 0 0
      server/public/water-mini/assets/pages-driver-routes.CvUQmn1S.js
  34. 0 0
      server/public/water-mini/assets/pages-driver-task.CpTGYle1.js
  35. 1 0
      server/public/water-mini/assets/pages-driver-tasks.C3iJ37G9.js
  36. 0 0
      server/public/water-mini/assets/pages-home-index.Ct_gqvgd.js
  37. 0 0
      server/public/water-mini/assets/pages-orders-checkout.G3zbKoaK.js
  38. 0 0
      server/public/water-mini/assets/pages-orders-index.DHIoWLJP.js
  39. 0 0
      server/public/water-mini/assets/pages-orders-tracking.CHarApRA.js
  40. 0 0
      server/public/water-mini/assets/pages-packages-cart.fjVhrnp4.js
  41. 0 0
      server/public/water-mini/assets/pages-packages-detail.BMJIBiNQ.js
  42. 0 0
      server/public/water-mini/assets/pages-packages-index.3E1P-liC.js
  43. 0 0
      server/public/water-mini/assets/pages-rights-index.D8PJL0N-.js
  44. 1 0
      server/public/water-mini/assets/pages-rights-ledger.BluxVjyW.js
  45. 1 0
      server/public/water-mini/assets/usePaged.CiTDNo9Z.js
  46. 1 0
      server/public/water-mini/assets/useResource.ZzB_qTLG.js
  47. 1 1
      server/public/water-mini/index.html
  48. 4 1
      server/tests/mini_sms_login_test.php
  49. 3 1
      water-mini/README.md
  50. 13 20
      water-mini/src/pages/account/login.vue
  51. 3 30
      water-mini/src/utils/login-flow.ts
  52. 176 0
      water-mini/tests/login-flow.test.mjs

+ 2 - 0
server/docs/WATER_MINI_API.md

@@ -90,6 +90,8 @@ routes/mini_api.php
 
 
 ## 2026-10-21 短信验证码登录
 ## 2026-10-21 短信验证码登录
 
 
+2026-10-23 前端修复:手机号表单统一调用 `/auth/send-sms` 和 `/auth/phone-login`,不再对测试号假报发送成功或转入 `/auth/test-phone`。入口仅由 `capabilities.sms_login` 控制,`test_phone_login` 不代表短信服务可用。发送成功才启动60秒倒计时,失败提示实际错误并允许重试;获取验证码不要求先同意协议,登录时仍要求同意。下方 `123456` 仅为请求格式示例,实际必须填写本次短信中的随机验证码。
+
 短信登录是微信登录的**第二种凭据**,不是独立注册通道:手机号必须先由超级管理员在后台绑定到**已存在**的小程序账号,才会真正下发短信。校验通过后登录到该账号,`user_id`、业务身份与权限和同一账号的微信登录完全一致。不会因为发短信而新建账号,未绑定的号码登录后仍无业务身份,与「1 个账号 1 个身份」的规则一致。
 短信登录是微信登录的**第二种凭据**,不是独立注册通道:手机号必须先由超级管理员在后台绑定到**已存在**的小程序账号,才会真正下发短信。校验通过后登录到该账号,`user_id`、业务身份与权限和同一账号的微信登录完全一致。不会因为发短信而新建账号,未绑定的号码登录后仍无业务身份,与「1 个账号 1 个身份」的规则一致。
 
 
 配置 `.env` 后重载 Webman,并以 `GET /config` 的 `capabilities.sms_login` 为准:
 配置 `.env` 后重载 Webman,并以 `GET /config` 的 `capabilities.sms_login` 为准:

+ 1 - 0
server/public/water-mini/assets/BottomNav.DMrVRe7J.js

@@ -0,0 +1 @@
+import{d as a,c as e,s,f as t,b as r,i as o,w as l,m as n,n as i,F as c,y as d,z as p,h as v,j as g,t as u,k as m,r as _,ad as f}from"./index-B8jJyCMw.js";import{_ as b}from"./_plugin-vue_export-helper.BCo6x5W8.js";const h=b(a({__name:"BottomNav",props:{active:{}},setup(a){const b=a;let h=!1;const x=e(()=>{var a,e;return"driver"===(null==(a=s.active)?void 0:a.role)?[["工作台","台","/pages/driver/dashboard"],["任务","务","/pages/driver/tasks"],["地图","图","/pages/driver/routes"],["我的","我","/pages/account/profile"]]:"agent"===(null==(e=s.active)?void 0:e.role)?[["工作台","台","/pages/agent/dashboard"],["用户","户","/pages/agent/customers"],["订单","单","/pages/agent/orders"],["佣金","佣","/pages/agent/commissions"]]:[["首页","首","/pages/home/index"],["订单","单","/pages/orders/index"],["水卡","卡","/pages/rights/index"],["我的","我","/pages/account/profile"]]});return(a,e)=>{const s=o,k=m,j=d;return t(),r(s,{class:"bottom-nav"},{default:l(()=>[(t(!0),n(c,null,i(x.value,e=>(t(),r(j,{key:e[2],class:p(["nav-item",{selected:a.active===e[2]}]),"aria-label":e[0]+(a.active===e[2]?",当前页面":""),onClick:a=>{var s;(s=e[2])===b.active||h||(h=!0,_({url:s,fail:()=>f({title:"页面打开失败,请重试",icon:"none"}),complete:()=>{h=!1}}))}},{default:l(()=>[v(s,{class:"nav-icon"},{default:l(()=>[g(u(e[1]),1)]),_:2},1024),v(k,{class:"nav-label"},{default:l(()=>[g(u(e[0]),1)]),_:2},1024)]),_:2},1032,["class","aria-label","onClick"]))),128))]),_:1})}}}),[["__scopeId","data-v-78314e07"]]);export{h as B};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/CommandStatus.vue_vue_type_script_setup_true_lang.BjITQA99.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/DevicePageHeader.BSYDrn_l.js


+ 1 - 0
server/public/water-mini/assets/IdentityGuard.CRrlQvEl.js

@@ -0,0 +1 @@
+import{d as a,c as s,s as t,ah as e,f as r,b as o,i as n,w as i,h as l,j as d,t as u,u as c,L as p,y as _}from"./index-B8jJyCMw.js";import{g as k}from"./format.Nrx3LY5T.js";import{_ as f}from"./_plugin-vue_export-helper.BCo6x5W8.js";const m=f(a({__name:"IdentityGuard",props:{kind:{}},setup(a){const f=a,m=s(()=>{var a,s;return!!t.token&&(!f.kind||("enterprise"===f.kind?null==(a=t.active)?void 0:a.role.startsWith("enterprise_"):(null==(s=t.active)?void 0:s.role)===f.kind))});return(a,s)=>{const f=n,v=_;return m.value?e(a.$slots,"default",{key:0},void 0,!0):(r(),o(f,{key:1,class:"card guard"},{default:i(()=>[l(f,{class:"icon-tile"},{default:i(()=>[d("泉")]),_:1}),l(f,{class:"title"},{default:i(()=>[d(u(c(t).token?"当前账号无此页面权限":"登录后查看专属服务"),1)]),_:1}),l(f,{class:"muted"},{default:i(()=>[d(u(c(t).token?"请使用对应业务账号登录,或联系管理员开通。":"查看企业用水、预约送水与配送进度"),1)]),_:1}),l(v,{class:"primary guard-action",onClick:s[0]||(s[0]=a=>c(k)(c(t).token?c(p)():"/pages/account/login"))},{default:i(()=>[d(u(c(t).token?"返回我的页面":"前往登录"),1)]),_:1})]),_:1}))}}}),[["__scopeId","data-v-9887a078"]]);export{m as I};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/PackageCard.o8o-Y5Md.js


+ 1 - 0
server/public/water-mini/assets/PageState.Czlpu_Kn.js

@@ -0,0 +1 @@
+import{d as e,f as a,b as s,i as t,w as l,h as r,j as o,k as n,t as p,y as u,v as d}from"./index-B8jJyCMw.js";import{_}from"./_plugin-vue_export-helper.BCo6x5W8.js";const y=_(e({__name:"PageState",props:{loading:{type:Boolean},error:{},empty:{type:Boolean},emptyText:{}},emits:["retry"],setup:e=>(e,_)=>{const y=t,m=n,c=u;return e.loading?(a(),s(y,{key:0,class:"state"},{default:l(()=>[r(y,{class:"spinner"}),r(m,null,{default:l(()=>[o("正在加载…")]),_:1})]),_:1})):e.error?(a(),s(y,{key:1,class:"state"},{default:l(()=>[r(y,{class:"state-symbol"},{default:l(()=>[o("!")]),_:1}),r(m,null,{default:l(()=>[o(p(e.error),1)]),_:1}),r(c,{class:"secondary button-small",onClick:_[0]||(_[0]=a=>e.$emit("retry"))},{default:l(()=>[o("重新加载")]),_:1})]),_:1})):e.empty?(a(),s(y,{key:2,class:"state"},{default:l(()=>[r(y,{class:"state-symbol"},{default:l(()=>[o("水")]),_:1}),r(m,null,{default:l(()=>[o(p(e.emptyText||"暂无记录"),1)]),_:1})]),_:1})):d("",!0)}}),[["__scopeId","data-v-3850482e"]]);export{y as P};

+ 1 - 0
server/public/water-mini/assets/PrivatePhotos.rphLut-F.js

@@ -0,0 +1 @@
+import{d as s,C as a,Q as e,D as t,f as o,b as r,i as u,w as c,h as n,m as l,n as i,q as d,j as p,t as h,F as f,y as v,v as m,s as _,a8 as k,a9 as b,aa as y,ab as C}from"./index-B8jJyCMw.js";import{_ as j}from"./_plugin-vue_export-helper.BCo6x5W8.js";const w=j(s({__name:"PrivatePhotos",props:{ids:{},scope:{},resourceId:{}},setup(s){const j=s,w=a([]),P=a("");let $=0;function x(s){s.startsWith("blob:")&&URL.revokeObjectURL(s)}function I(){w.value.forEach(s=>x(s.path)),w.value=[]}async function E(){const s=++$;if(I(),P.value="",!_.token)return;const a=_.token,e=_.epoch;for(const t of j.ids){if(s!==$||e!==_.epoch)return;try{const o=await new Promise((s,e)=>k({url:`${y}/photos/${t}?scope=${j.scope}&resource_id=${j.resourceId}`,header:{Authorization:`Bearer ${a}`},success(t){200===t.statusCode?s(t.tempFilePath):(401===t.statusCode&&a===_.token&&b(),e(new Error("照片读取失败")))},fail:()=>e(new Error("照片读取失败"))}));if(s!==$||e!==_.epoch)return void x(o);w.value.push({id:t,path:o})}catch{s===$&&(P.value="部分照片加载失败,请重新加载")}}}return e(()=>[j.ids.join(","),j.resourceId,_.epoch],E,{immediate:!0,flush:"sync"}),t(()=>{$++,I()}),(s,a)=>{const e=d,t=u,_=v;return o(),r(t,null,{default:c(()=>[n(t,{class:"grid"},{default:c(()=>[(o(!0),l(f,null,i(w.value,s=>(o(),r(t,{key:s.id},{default:c(()=>[n(e,{src:s.path,class:"photo",mode:"aspectFill",onClick:a=>{return e=s.path,void C({current:e,urls:w.value.map(s=>s.path)});var e}},null,8,["src","onClick"]),n(t,{class:"muted small"},{default:c(()=>[p("照片 #"+h(s.id),1)]),_:2},1024)]),_:2},1024))),128))]),_:1}),P.value?(o(),r(_,{key:0,class:"text-button",onClick:E},{default:c(()=>[p(h(P.value),1)]),_:1})):m("",!0)]),_:1})}}}),[["__scopeId","data-v-2d023a57"]]);export{w as P};

+ 1 - 0
server/public/water-mini/assets/QuantityInput.D0eRj7qp.js

@@ -0,0 +1 @@
+import{d as e,f as a,b as l,i as t,w as u,h as d,j as s,y as m,I as n}from"./index-B8jJyCMw.js";import{_ as o}from"./_plugin-vue_export-helper.BCo6x5W8.js";const i=o(e({__name:"QuantityInput",props:{modelValue:{},min:{default:1},max:{default:100}},emits:["update:modelValue"],setup(e,{emit:o}){const i=e,r=o;function p(e){r("update:modelValue",Math.min(i.max,Math.max(i.min,Math.trunc(e)||i.min)))}function c(e){p(Number(e.detail.value))}return(e,o)=>{const i=m,r=n,f=t;return a(),l(f,{class:"stepper"},{default:u(()=>[d(i,{disabled:e.modelValue<=e.min,onClick:o[0]||(o[0]=a=>p(e.modelValue-1))},{default:u(()=>[s("−")]),_:1},8,["disabled"]),d(r,{value:String(e.modelValue),type:"number",onBlur:c},null,8,["value"]),d(i,{disabled:e.modelValue>=e.max,onClick:o[1]||(o[1]=a=>p(e.modelValue+1))},{default:u(()=>[s("+")]),_:1},8,["disabled"])]),_:1})}}}),[["__scopeId","data-v-fc2725bb"]]);export{i as Q};

+ 1 - 0
server/public/water-mini/assets/TaskCard.vue_vue_type_script_setup_true_lang.BQCYiRye.js

@@ -0,0 +1 @@
+import{d as s,f as a,b as e,i as t,u as l,w as d,h as i,k as m,z as r,j as u,t as c}from"./index-B8jJyCMw.js";import{g as _,l as f}from"./format.Nrx3LY5T.js";const n=s({__name:"TaskCard",props:{item:{}},setup:s=>(s,n)=>{const o=m,p=t;return a(),e(p,{class:"card",onClick:n[0]||(n[0]=a=>l(_)("/pages/driver/task?id="+s.item.id))},{default:d(()=>[i(p,{class:"row"},{default:d(()=>[i(o,{class:r(["tag","completed"===s.item.status?"green":"orange"])},{default:d(()=>[u(c(l(f)(s.item.status)),1)]),_:1},8,["class"]),i(o,{class:"muted small"},{default:d(()=>[u(c(s.item.code),1)]),_:1})]),_:1}),i(p,{class:"title",style:{margin:"20rpx 0 10rpx"}},{default:d(()=>[u(c(s.item.enterprise_name||"企业配送任务"),1)]),_:1}),i(p,{class:"muted"},{default:d(()=>[u(c(s.item.address),1)]),_:1}),i(p,{class:"divider"}),i(p,{class:"row"},{default:d(()=>[i(p,null,{default:d(()=>[i(p,null,{default:d(()=>[u(c(s.item.sku_name)+" · "+c(s.item.quantity)+"桶",1)]),_:1}),i(p,{class:"muted"},{default:d(()=>[u(c(s.item.scheduled_date||"待安排日期"),1)]),_:1})]),_:1}),i(o,{class:"tag"},{default:d(()=>[u("查看任务 ›")]),_:1})]),_:1})]),_:1})}});export{n as _};

+ 1 - 0
server/public/water-mini/assets/cart.BmrEUm50.js

@@ -0,0 +1 @@
+import{Y as t}from"./index-B8jJyCMw.js";import{t as i}from"./format.Nrx3LY5T.js";function n(t){return u.items.find(i=>i.product.id===t)}const u=t({items:[]});function s(t){const s=n(t.id);if(s)s.quantity<100&&s.quantity++;else{if(!(u.items.length<20))return void i("最多选择20种套餐");u.items.push({product:t,quantity:1})}i("已加入订单")}function o(t){return n(t.id)?(u.items=u.items.filter(i=>i.product.id!==t.id),i("已取消选择"),!1):u.items.length>=20?(i("最多选择20种套餐"),!1):(u.items.push({product:t,quantity:1}),i("已选择,可继续选择其他套餐"),!0)}function c(t){const i=n(t.id);i&&i.quantity<100&&i.quantity++}function e(t){const i=n(t.id);i&&(i.quantity>1?i.quantity--:o(t))}function r(t){var i;return(null==(i=n(t))?void 0:i.quantity)??0}function a(){return u.items.map(t=>({package_id:t.product.id,quantity:t.quantity}))}function d(t){for(const i of u.items){const n=t.find(t=>t.id===i.product.id);n&&(i.product=n)}}function f(t){for(const i of u.items){const n=t.items.find(t=>t.package_id===i.product.id);if(!n||n.quantity!==i.quantity)throw new Error("套餐选择已变化,请重新报价");const u=i.product.sku_id===n.sku_id;i.product={...i.product,name:n.name,sku_id:n.sku_id,sku_name:u?i.product.sku_name:null,liters:u?i.product.liters:null,buckets:n.buckets/n.quantity,price_cents:n.unit_price_cents,unit_price_cents:Math.round(n.amount_cents/n.buckets)}}}function m(){u.items=[]}export{s as a,a as b,u as c,e as d,f as e,m as f,c as i,r as q,d as r,o as t};

+ 1 - 0
server/public/water-mini/assets/format.Nrx3LY5T.js

@@ -0,0 +1 @@
+import{ad as n,ai as e,a0 as i}from"./index-B8jJyCMw.js";function r(n,e=2){return null==n?"—":(n/100).toLocaleString("zh-CN",{minimumFractionDigits:e,maximumFractionDigits:e})}function t(n,e=""){return null==n?"—":`${n}${e}`}const o={pending:"待调度",assigned:"待出发",transit:"配送中",completed:"已完成",cancelled:"已取消",returning:"待退货",returned:"已退货",expired:"已过期",paid:"已支付",online:"在线",offline:"离线",unknown:"状态未知",enterprise_admin:"企业管理员",enterprise_member:"企业成员",driver:"配送员",agent:"代理商"};function a(n){return n?o[n]||n:"—"}function s(n){e({url:n})}function u(e){n({title:e,icon:"none",duration:2600})}function c(n,e){return new Promise(r=>i({title:n,content:e,success:n=>r(n.confirm),fail:()=>r(!1)}))}function m(n){const e=Number(n);if(!Number.isSafeInteger(e)||e<1)throw new Error("页面参数无效,请返回列表重新进入");return e}export{c,s as g,m as i,a as l,r as m,u as t,t as v};

Diff do ficheiro suprimidas por serem muito extensas
+ 2 - 0
server/public/water-mini/assets/index-B8jJyCMw.js


+ 1 - 0
server/public/water-mini/assets/location.BzU4PAth.js

@@ -0,0 +1 @@
+import{ad as e,ae as n}from"./index-B8jJyCMw.js";import{g as t}from"./format.Nrx3LY5T.js";function o(){return"undefined"==typeof window||window.isSecureContext?new Promise((e,t)=>n({type:"gcj02",success:n=>e({latitude:n.latitude,longitude:n.longitude}),fail:e=>{const n=e,o=Number(n.code),r=(n.errMsg||"").toLowerCase();1===o||/permission|denied|auth deny|unauthorized/.test(r)?t(new Error("定位权限被拒绝,请在浏览器网站设置和系统设置中允许位置访问")):2===o||/unavailable/.test(r)?t(new Error("设备暂时无法确定位置,请开启系统定位服务并检查网络后重试")):3===o||/timeout/.test(r)?t(new Error("定位超时,请检查网络或移动到信号较好的位置后重试")):t(new Error("无法获取位置,请检查浏览器定位权限与系统定位服务后重试"))}})):Promise.reject(new Error("当前网页地址不支持定位,请使用 HTTPS,或在本机通过 127.0.0.1 打开"))}function r(n,o,r){if(null==n||null==o||""===n||""===o)return void e({title:"该地址尚未定位",icon:"none"});const i=Number(n),s=Number(o);!Number.isFinite(i)||!Number.isFinite(s)||Math.abs(i)>90||Math.abs(s)>180||t("/pages/common/map?latitude="+encodeURIComponent(String(i))+"&longitude="+encodeURIComponent(String(s))+"&address="+encodeURIComponent(r||"配送地址"))}export{o as l,r as o};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/login-Cel95xNG.css


+ 1 - 0
server/public/water-mini/assets/pages-account-identities.BFPFYYjG.js

@@ -0,0 +1 @@
+import{d as a,o as s,s as t,b as e,w as l,i as o,M as r,r as u,L as n,f as d,h as i,j as c,y as f,u as _,k as m,t as p,v as y,N as g}from"./index-B8jJyCMw.js";import{u as j}from"./useResource.ZzB_qTLG.js";import{I as k}from"./IdentityGuard.CRrlQvEl.js";import{P as v}from"./PageState.Czlpu_Kn.js";import"./format.Nrx3LY5T.js";import"./_plugin-vue_export-helper.BCo6x5W8.js";const w=a({__name:"identities",setup(a){const{loading:w,error:x,load:I}=j(async()=>(await r(),t.active&&u({url:n()}),!0));function b(){g({data:t.userId})}return s(()=>{t.token&&I()}),(a,s)=>{const r=o,u=f,n=m;return d(),e(r,{class:"page"},{default:l(()=>[i(k,null,{default:l(()=>[i(r,{class:"section"},{default:l(()=>[i(r,{class:"title"},{default:l(()=>[c("账号服务状态")]),_:1}),i(u,{class:"text-button",onClick:_(I)},{default:l(()=>[c("刷新")]),_:1},8,["onClick"])]),_:1}),i(r,{class:"muted"},{default:l(()=>[c("账号的服务身份由管理员绑定,登录后自动进入对应页面。")]),_:1}),i(v,{loading:_(w),error:_(x),onRetry:_(I)},null,8,["loading","error","onRetry"]),_(w)||_(x)||_(t).identities.length?y("",!0):(d(),e(r,{key:0,class:"card"},{default:l(()=>[i(r,{class:"title"},{default:l(()=>[c("账号已建立,等待绑定身份")]),_:1}),i(r,{class:"notice"},{default:l(()=>[c("请将用户编号提供给管理员,绑定企业、配送员或代理商后,点击刷新。")]),_:1}),i(r,{class:"row"},{default:l(()=>[i(n,{class:"muted"},{default:l(()=>[c("用户编号:"+p(_(t).userId),1)]),_:1}),i(u,{class:"secondary button-small",onClick:b},{default:l(()=>[c("复制编号")]),_:1})]),_:1})]),_:1}))]),_:1})]),_:1})}}});export{w as default};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-account-login.DxUd7oTQ.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-account-profile.CYhkfqgS.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-account-rewards.D8Gb4W8E.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-addresses-edit.BpGo-jw3.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-addresses-index.BUcClaJP.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-agent-commissions.CqrzaVTY.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-agent-customers.Dwac9rXw.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-agent-dashboard.BHD7JxGx.js


+ 1 - 0
server/public/water-mini/assets/pages-agent-orders.BanYtBXs.js

@@ -0,0 +1 @@
+import{d as a,C as e,U as s,o as t,s as r,R as l,b as o,w as d,i as n,g as u,e as i,f as m,h as c,j as p,t as _,m as f,F as g,n as v,u as y,k as j,y as k,v as x}from"./index-B8jJyCMw.js";import{u as b}from"./usePaged.CiTDNo9Z.js";import{m as h}from"./format.Nrx3LY5T.js";import{I as w}from"./IdentityGuard.CRrlQvEl.js";import{P as B}from"./PageState.Czlpu_Kn.js";import{B as C}from"./BottomNav.DMrVRe7J.js";import"./_plugin-vue_export-helper.BCo6x5W8.js";const P=a({__name:"orders",setup(a){const P=e(0),{items:I,loading:N,error:R,hasMore:z,load:A}=b(a=>u(i("agent")+"/orders",{page:a,...P.value?{enterprise_id:P.value}:{}}));return s(a=>{P.value=Number(null==a?void 0:a.enterprise_id)||0}),t(()=>{var a;"agent"===(null==(a=r.active)?void 0:a.role)&&A(!0)}),l(()=>A()),(a,e)=>{const s=n,t=j,r=k;return m(),o(s,{class:"page"},{default:d(()=>[c(w,{kind:"agent"},{default:d(()=>[c(s,{class:"notice"},{default:d(()=>[p(_(P.value?"当前客户的真实收款记录":"当前辖区客户的真实收款记录"),1)]),_:1}),(m(!0),f(g,null,v(y(I),a=>(m(),o(s,{key:a.id,class:"card"},{default:d(()=>[c(s,{class:"row"},{default:d(()=>[c(t,{class:"strong"},{default:d(()=>[p(_(a.package_name),1)]),_:2},1024),c(t,{class:"tag green"},{default:d(()=>[p("已收款")]),_:1})]),_:2},1024),c(s,{class:"muted",style:{"margin-top":"14rpx"}},{default:d(()=>[p(_(a.created_at),1)]),_:2},1024),c(s,{class:"divider"}),c(s,{class:"row"},{default:d(()=>[c(t,{class:"muted"},{default:d(()=>[p(_(a.buckets)+"桶 · 企业编号 "+_(a.enterprise_id),1)]),_:2},1024),c(t,{class:"amount"},{default:d(()=>[p("¥"+_(y(h)(a.amount_cents)),1)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128)),c(B,{loading:y(N),error:y(R),empty:!y(I).length,onRetry:e[0]||(e[0]=a=>y(A)(!0))},null,8,["loading","error","empty"]),y(z)&&!y(N)?(m(),o(r,{key:0,class:"text-button",onClick:e[1]||(e[1]=a=>y(A)())},{default:d(()=>[p("加载更多")]),_:1})):x("",!0)]),_:1}),c(C,{active:"/pages/agent/orders"})]),_:1})}}});export{P as default};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-common-map.CskUSDOy.js


+ 1 - 0
server/public/water-mini/assets/pages-common-web.CJMHF_mv.js

@@ -0,0 +1 @@
+import{d as a,C as s,U as e,b as t,w as n,v as l,i as r,aj as o,f as u,h as c,j as i}from"./index-B8jJyCMw.js";const d=a({__name:"web",setup(a){const d=s(""),v=s(!1);return e(a=>{try{const s=decodeURIComponent((null==a?void 0:a.url)||"");if(!/^https:\/\/[^\s]+$/i.test(s))throw new Error("invalid");d.value=s}catch{v.value=!0}}),(a,s)=>{const e=r,f=o;return v.value?(u(),t(e,{key:0,class:"page"},{default:n(()=>[c(e,{class:"notice warning"},{default:n(()=>[i("该服务链接暂不可用,请联系平台。")]),_:1})]),_:1})):d.value?(u(),t(f,{key:1,src:d.value},null,8,["src"])):l("",!0)}}});export{d as default};

+ 1 - 0
server/public/water-mini/assets/pages-devices-detail.D1IVPWUZ.js

@@ -0,0 +1 @@
+import{d as e,C as a,U as s,b as r,w as o,i as t,g as d,e as i,f as n,h as l,u,v as m}from"./index-B8jJyCMw.js";import{u as p}from"./useResource.ZzB_qTLG.js";import{i as f,g}from"./format.Nrx3LY5T.js";import{I as v}from"./IdentityGuard.CRrlQvEl.js";import{P as c}from"./PageState.Czlpu_Kn.js";import{D as _,a as j}from"./DevicePageHeader.BSYDrn_l.js";import{_ as y}from"./_plugin-vue_export-helper.BCo6x5W8.js";const x=y(e({__name:"detail",setup(e){const y=a(0),{data:x,loading:I,error:P,load:R}=p(()=>d(i("enterprise")+"/devices/"+f(y.value)));return s(e=>{y.value=Number(null==e?void 0:e.id),R()}),(e,a)=>{const s=t;return n(),r(s,{class:"device-page"},{default:o(()=>[l(_,{onManage:a[0]||(a[0]=e=>u(g)("/pages/devices/index"))}),l(v,{kind:"enterprise"},{default:o(()=>[l(c,{loading:u(I),error:u(P),onRetry:u(R)},null,8,["loading","error","onRetry"]),!u(x)||u(I)||u(P)?m("",!0):(n(),r(j,{key:0,data:u(x)},null,8,["data"]))]),_:1})]),_:1})}}}),[["__scopeId","data-v-d397884e"]]);export{x as default};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-devices-index.D8cXtdjL.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-driver-dashboard.C91gVQiE.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-driver-device.Cc_txlLK.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-driver-fulfill.jxyb4RsP.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-driver-routes.CvUQmn1S.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-driver-task.CpTGYle1.js


+ 1 - 0
server/public/water-mini/assets/pages-driver-tasks.C3iJ37G9.js

@@ -0,0 +1 @@
+import{d as a,C as s,o as e,s as t,R as r,a as o,b as l,w as i,i as d,g as n,e as u,f as m,h as p,m as c,F as v,n as _,u as f,z as g,j as y,t as k,y as j,v as C}from"./index-B8jJyCMw.js";import{u as x}from"./usePaged.CiTDNo9Z.js";import{I as h}from"./IdentityGuard.CRrlQvEl.js";import{P as I}from"./PageState.Czlpu_Kn.js";import{_ as B}from"./TaskCard.vue_vue_type_script_setup_true_lang.BQCYiRye.js";import{B as P}from"./BottomNav.DMrVRe7J.js";import{_ as b}from"./_plugin-vue_export-helper.BCo6x5W8.js";import"./format.Nrx3LY5T.js";const w=b(a({__name:"tasks",setup(a){const b=s("all"),w=[["all","全部"],["pending","待出发"],["transit","配送中"],["completed","已完成"],["exception","异常"]],{items:z,loading:R,error:A,hasMore:F,load:G}=x(a=>n(u("driver")+"/tasks",{page:a,status:b.value}));return e(()=>{var a;"driver"===(null==(a=t.active)?void 0:a.role)&&G(!0)}),r(()=>G()),o(()=>G(!0)),(a,s)=>{const e=d,t=j;return m(),l(e,{class:"page"},{default:i(()=>[p(h,{kind:"driver"},{default:i(()=>[p(e,{class:"tabs"},{default:i(()=>[(m(),c(v,null,_(w,a=>p(e,{key:a[0],class:g({active:b.value===a[0]}),onClick:s=>{b.value=a[0],f(G)(!0)}},{default:i(()=>[y(k(a[1]),1)]),_:2},1032,["class","onClick"])),64))]),_:1}),(m(!0),c(v,null,_(f(z),a=>(m(),l(B,{key:a.id,item:a},null,8,["item"]))),128)),p(I,{loading:f(R),error:f(A),empty:!f(z).length,"empty-text":"当前分类暂无任务",onRetry:s[0]||(s[0]=a=>f(G)(!0))},null,8,["loading","error","empty"]),f(F)&&!f(R)?(m(),l(t,{key:0,class:"driver-action driver-action--secondary load-more",onClick:s[1]||(s[1]=a=>f(G)())},{default:i(()=>[y(" 加载更多 ")]),_:1})):C("",!0)]),_:1}),p(P,{active:"/pages/driver/tasks"})]),_:1})}}}),[["__scopeId","data-v-1838c1de"]]);export{w as default};

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-home-index.Ct_gqvgd.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-orders-checkout.G3zbKoaK.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-orders-index.DHIoWLJP.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-orders-tracking.CHarApRA.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-packages-cart.fjVhrnp4.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-packages-detail.BMJIBiNQ.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-packages-index.3E1P-liC.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
server/public/water-mini/assets/pages-rights-index.D8PJL0N-.js


+ 1 - 0
server/public/water-mini/assets/pages-rights-ledger.BluxVjyW.js

@@ -0,0 +1 @@
+import{d as e,o as a,s,R as t,b as r,w as l,i as o,g as n,e as d,f as i,h as u,j as p,m as c,F as m,n as _,u as f,t as g,k as y,y as v,v as j}from"./index-B8jJyCMw.js";import{u as k}from"./usePaged.CiTDNo9Z.js";import{I as h}from"./IdentityGuard.CRrlQvEl.js";import{P as x}from"./PageState.Czlpu_Kn.js";import"./format.Nrx3LY5T.js";import"./_plugin-vue_export-helper.BCo6x5W8.js";const w=e({__name:"ledger",setup(e){const w={purchase:"购买入账",promotion_reward:"推广赠水权益",reserve:"配送冻结",cancel:"取消释放",complete:"完成配送",partial_receive:"部分签收",delivery_return:"配送退货"},{items:P,loading:b,error:C,hasMore:I,load:R}=k(e=>n(d("enterprise")+"/ledger",{page:e}));return a(()=>{var e;(null==(e=s.active)?void 0:e.role.startsWith("enterprise_"))&&R(!0)}),t(()=>R()),(e,a)=>{const s=o,t=y,n=v;return i(),r(s,{class:"page"},{default:l(()=>[u(h,{kind:"enterprise"},{default:l(()=>[u(s,{class:"notice"},{default:l(()=>[p("显示每次业务操作的桶数,并非全部代表权益扣减。")]),_:1}),(i(!0),c(m,null,_(f(P),e=>(i(),r(s,{key:e.id,class:"card"},{default:l(()=>[u(s,{class:"row"},{default:l(()=>[u(s,null,{default:l(()=>[u(s,{class:"strong"},{default:l(()=>[p(g(w[e.kind]||e.kind),1)]),_:2},1024),u(s,{class:"muted"},{default:l(()=>[p(g(e.created_at),1)]),_:2},1024)]),_:2},1024),u(t,{class:"amount"},{default:l(()=>[p(g(e.quantity)+"桶",1)]),_:2},1024)]),_:2},1024),u(s,{class:"muted",style:{"margin-top":"15rpx"}},{default:l(()=>[p(g(e.reference),1)]),_:2},1024)]),_:2},1024))),128)),u(x,{loading:f(b),error:f(C),empty:!f(P).length,onRetry:a[0]||(a[0]=e=>f(R)(!0))},null,8,["loading","error","empty"]),f(I)&&!f(b)?(i(),r(n,{key:0,class:"text-button",onClick:a[1]||(a[1]=e=>f(R)())},{default:l(()=>[p("加载更多")]),_:1})):j("",!0)]),_:1})]),_:1})}}});export{w as default};

+ 1 - 0
server/public/water-mini/assets/usePaged.CiTDNo9Z.js

@@ -0,0 +1 @@
+import{af as a,C as e,Q as u,s as l,ag as s}from"./index-B8jJyCMw.js";function t(t){const r=a(null),v=a([]),o=e(!1),n=e(""),c=e(!1),i=e(0),m=e({});let f=0,h=0;return u(()=>l.epoch,()=>{h++,r.value=null,v.value=[],o.value=!1,n.value="",f=0,i.value=0,m.value={},c.value=!1},{flush:"sync"}),{data:r,items:v,loading:o,error:n,hasMore:c,total:i,summary:m,load:async function(a=!1){if(o.value&&!a)return;if(!a&&f>0&&!c.value)return;const e=a?++h:h,u=l.epoch,y=a?1:f+1;a&&(v.value=[],f=0,c.value=!1,i.value=0,m.value={}),o.value=!0,n.value="";try{const s=await t(y);if(e!==h||u!==l.epoch)return;r.value=s,v.value=a?s.items:[...v.value,...s.items],i.value=s.total,c.value=s.has_more,s.summary&&(m.value=s.summary),f=y}catch(p){e===h&&u===l.epoch&&(n.value=p instanceof Error?p.message:"加载失败")}finally{e===h&&(o.value=!1),s()}}}}export{t as u};

+ 1 - 0
server/public/water-mini/assets/useResource.ZzB_qTLG.js

@@ -0,0 +1 @@
+import{af as a,C as e,Q as l,s as n,ag as o}from"./index-B8jJyCMw.js";function s(s){const u=a(null),c=e(!1),t=e("");let r=0;return l(()=>n.epoch,()=>{r++,u.value=null,c.value=!1,t.value=""},{flush:"sync"}),{data:u,loading:c,error:t,load:async function(){const a=++r,e=n.epoch;c.value=!0,t.value="";try{const l=await s();a===r&&e===n.epoch&&(u.value=l)}catch(l){a===r&&e===n.epoch&&(u.value=null,t.value=l instanceof Error?l.message:"加载失败")}finally{a===r&&(c.value=!1),o()}}}}export{s as u};

+ 1 - 1
server/public/water-mini/index.html

@@ -6,7 +6,7 @@
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
     <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
     <title>云泉智配</title>
     <title>云泉智配</title>
-    <script type="module" crossorigin src="/water-mini/assets/index-CxEYQ6kj.js"></script>
+    <script type="module" crossorigin src="/water-mini/assets/index-B8jJyCMw.js"></script>
     <link rel="stylesheet" crossorigin href="/water-mini/assets/index-DY2jyvfz.css">
     <link rel="stylesheet" crossorigin href="/water-mini/assets/index-DY2jyvfz.css">
   </head>
   </head>
   <body>
   <body>

+ 4 - 1
server/tests/mini_sms_login_test.php

@@ -114,6 +114,7 @@ try {
     // Issuing keeps only a hash and hands the plaintext to the sender.
     // Issuing keeps only a hash and hands the plaintext to the sender.
     $login->sendCode($input, '10.0.0.9');
     $login->sendCode($input, '10.0.0.9');
     check(count($sender->sent) === 1, 'one message delivered');
     check(count($sender->sent) === 1, 'one message delivered');
+    check(preg_match('/^[1-9][0-9]{5}$/D', $sender->lastCode()) === 1, 'six-digit random code delivered');
     $row = MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->first();
     $row = MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->first();
     check(preg_match('/^[a-f0-9]{64}$/D', (string) $row->code_hash) === 1, 'code stored as a hash');
     check(preg_match('/^[a-f0-9]{64}$/D', (string) $row->code_hash) === 1, 'code stored as a hash');
     check((string) $row->request_ip === '10.0.0.9', 'request address recorded');
     check((string) $row->request_ip === '10.0.0.9', 'request address recorded');
@@ -161,6 +162,8 @@ try {
     MiniSmsCode::query()->where('phone', $phone)->delete();
     MiniSmsCode::query()->where('phone', $phone)->delete();
     $login->sendCode($input, '127.0.0.1');
     $login->sendCode($input, '127.0.0.1');
     $live = $sender->lastCode();
     $live = $sender->lastCode();
+    $wrong = $live === '123456' ? '654321' : '123456';
+    reject(fn () => $login->login($input + ['code' => $wrong]), 40102, 'no fixed-code bypass');
     $session = $login->login($input + ['code' => $live]);
     $session = $login->login($input + ['code' => $live]);
     check($session['user_id'] === (string) $userId, 'logged into the bound account');
     check($session['user_id'] === (string) $userId, 'logged into the bound account');
     check(is_array($session['identities']), 'identities returned');
     check(is_array($session['identities']), 'identities returned');
@@ -192,4 +195,4 @@ try {
     $db->rollBack();
     $db->rollBack();
     settings($original);
     settings($original);
 }
 }
-echo "PASS: $checks SMS login checks; no message sent and all changes rolled back.\n";
+echo "PASS: $checks SMS login checks; no message sent and all changes rolled back.\n";

+ 3 - 1
water-mini/README.md

@@ -8,6 +8,8 @@ uni-app Vue 3 + TypeScript,微信小程序为主要交付端,同时提供 H5
 
 
 ## 本地测试手机号登录
 ## 本地测试手机号登录
 
 
+此节的固定码仅用于直接调用本地 `/auth/test-phone` 测试接口。页面上的“手机验证码登录”统一使用真实短信接口,不再对测试号跳过发送或走固定码登录;只有服务器公布 `capabilities.sms_login=true` 时才开放。获取验证码无需先勾选协议,提交登录仍需同意协议。短信仅发送到后台已绑定的手机号,随机六位码有效期为5分钟,不存在固定通用验证码。
+
 在后端 `.env` 设置 `APP_ENV=local` 和 `MINI_TEST_PHONE_LOGIN=true`,执行 `php -d extension=mbstring scripts/seed_mini_test_phone.php --apply` 并重启后端。本地固定验证码均为 `66666`,不发送短信。每个测试账号只绑定一种身份,登录后直接进入对应页面:
 在后端 `.env` 设置 `APP_ENV=local` 和 `MINI_TEST_PHONE_LOGIN=true`,执行 `php -d extension=mbstring scripts/seed_mini_test_phone.php --apply` 并重启后端。本地固定验证码均为 `66666`,不发送短信。每个测试账号只绑定一种身份,登录后直接进入对应页面:
 
 
 | 角色 | 测试手机号 | 登录后页面 |
 | 角色 | 测试手机号 | 登录后页面 |
@@ -16,7 +18,7 @@ uni-app Vue 3 + TypeScript,微信小程序为主要交付端,同时提供 H5
 | 配送员 | `13012345679` | 配送工作台 |
 | 配送员 | `13012345679` | 配送工作台 |
 | 代理商 | `13012345680` | 代理工作台 |
 | 代理商 | `13012345680` | 代理工作台 |
 
 
-初始化脚本仅绑定指定的既有测试档案(科技园配送测试、地图配送员-KJY、测试商贸),并停用原企业测试号上的司机、代理授权;不新增订单、库存或收款。重复运行不恢复被管理员停用的账号或身份。关闭开关并重启后端后,既有测试 Token 同时失效。正式部署保持 `APP_ENV=production`、`MINI_TEST_PHONE_LOGIN=false`。真实手机号短信登录仍待接入短信服务。
+初始化脚本仅绑定指定的既有测试档案(科技园配送测试、地图配送员-KJY、测试商贸),并停用原企业测试号上的司机、代理授权;不新增订单、库存或收款。重复运行不恢复被管理员停用的账号或身份。关闭开关并重启后端后,既有测试 Token 同时失效。正式部署保持 `APP_ENV=production`、`MINI_TEST_PHONE_LOGIN=false`。真实短信服务的配置、手机号绑定及迁移说明见 `../server/docs/WATER_MINI_API.md` 的“短信验证码登录”。
 
 
 ## 安装与运行
 ## 安装与运行
 
 

+ 13 - 20
water-mini/src/pages/account/login.vue

@@ -16,13 +16,7 @@ const sendingCode = ref(false);
 const codeCountdown = ref(0);
 const codeCountdown = ref(0);
 let countdownTimer: ReturnType<typeof setInterval> | null = null;
 let countdownTimer: ReturnType<typeof setInterval> | null = null;
 
 
-// 真实短信登录与本地测试号登录共用同一张表单,开关由服务器 capabilities 决定。
-const phoneLoginAvailable = computed(
-  () =>
-    Boolean(session.configuration?.capabilities.sms_login) ||
-    Boolean(session.configuration?.capabilities.test_phone_login),
-);
-
+const phoneLoginAvailable = computed(() => Boolean(session.configuration?.capabilities.sms_login));
 
 
 // 隐私协议跳转
 // 隐私协议跳转
 function privacy() {
 function privacy() {
@@ -90,20 +84,23 @@ async function login() {
 }
 }
 
 
 async function sendCode() {
 async function sendCode() {
-  if (!agreed.value) {
-    toast('请先阅读并同意服务说明');
-    return;
-  }
   if (sendingCode.value || codeCountdown.value > 0) return;
   if (sendingCode.value || codeCountdown.value > 0) return;
   if (!/^1[3-9]\d{9}$/.test(phone.value)) {
   if (!/^1[3-9]\d{9}$/.test(phone.value)) {
     toast('请输入正确的手机号码');
     toast('请输入正确的手机号码');
     return;
     return;
   }
   }
-  if (!phoneLoginAvailable.value) return;
+  if (!phoneLoginAvailable.value) {
+    toast('短信登录暂未开放,请联系平台管理员');
+    return;
+  }
   sendingCode.value = true;
   sendingCode.value = true;
   try {
   try {
     const ok = await requestPhoneCode(phone.value);
     const ok = await requestPhoneCode(phone.value);
-    if (ok) startCountdown();
+    if (ok) {
+      phoneCode.value = '';
+      startCountdown();
+      toast('请求已提交,请查看手机短信');
+    }
   } finally {
   } finally {
     sendingCode.value = false;
     sendingCode.value = false;
   }
   }
@@ -121,13 +118,7 @@ async function loginByPhone() {
   if (busy.value || !phoneLoginAvailable.value) return;
   if (busy.value || !phoneLoginAvailable.value) return;
   busy.value = true;
   busy.value = true;
   try {
   try {
-    acceptLogin(
-      await loginByPhoneCode(
-        phone.value,
-        phoneCode.value,
-        Boolean(session.configuration?.capabilities.test_phone_login),
-      ),
-    );
+    acceptLogin(await loginByPhoneCode(phone.value, phoneCode.value));
     uni.reLaunch({ url: accountPage() });
     uni.reLaunch({ url: accountPage() });
   } catch (error) {
   } catch (error) {
     toast(error instanceof Error ? error.message : '登录失败');
     toast(error instanceof Error ? error.message : '登录失败');
@@ -189,12 +180,14 @@ async function loginByPhone() {
           />
           />
           <button
           <button
             class="code-button"
             class="code-button"
+            :loading="sendingCode"
             :disabled="sendingCode || codeCountdown > 0"
             :disabled="sendingCode || codeCountdown > 0"
             @click="sendCode"
             @click="sendCode"
           >
           >
             {{ codeCountdown > 0 ? codeCountdown + 's 后重试' : sendingCode ? '发送中' : '获取验证码' }}
             {{ codeCountdown > 0 ? codeCountdown + 's 后重试' : sendingCode ? '发送中' : '获取验证码' }}
           </button>
           </button>
         </view>
         </view>
+        <view class="muted">请使用后台已绑定的手机号,验证码以收到的短信为准。</view>
         <button class="primary" :loading="busy" :disabled="busy" @click="loginByPhone">
         <button class="primary" :loading="busy" :disabled="busy" @click="loginByPhone">
           登录
           登录
         </button>
         </button>

+ 3 - 30
water-mini/src/utils/login-flow.ts

@@ -1,28 +1,10 @@
-// 登录流程分流:测试触发器与短信通道分离
-//
-// - 三个独立测试账号共用本地固定验证码,只在开发环境、且服务端开启
-//   MINI_TEST_PHONE_LOGIN 时由 /auth/test-phone 受理;不向用户展示
-// - 其他手机号走真实短信通道:/auth/send-sms 下发,/auth/phone-login 校验。
-//   服务端只对"后台已绑定手机号"的账号真正下发短信,且两种情况返回一致,
-//   以免暴露哪些号码已注册
-//
-// 注意:不要把测试号与验证码散落到 UI 文件或注释里;它们只是内部
-// 触发器,不是给用户看的入口。
+// 手机验证码表单统一使用服务端生成、发送和校验的随机短信验证码。
 
 
 import { post } from '../api/client';
 import { post } from '../api/client';
 import { toast } from './format';
 import { toast } from './format';
 import type { Login } from '../api/schema';
 import type { Login } from '../api/schema';
 
 
-const TEST_PHONES = new Set(['13012345678', '13012345679', '13012345680']);
-
-// 仅用于内部判断"这条手机号是不是测试触发器",不要在 UI 文案里使用
-export function isTestPhone(phone: string): boolean {
-  return TEST_PHONES.has(phone);
-}
-
-// 取短信验证码:测试号直接返回成功(不发短信);其他号调 /auth/send-sms
 export async function requestPhoneCode(phone: string): Promise<boolean> {
 export async function requestPhoneCode(phone: string): Promise<boolean> {
-  if (isTestPhone(phone)) return true;
   try {
   try {
     await post('/auth/send-sms', { phone }, false);
     await post('/auth/send-sms', { phone }, false);
     return true;
     return true;
@@ -32,15 +14,6 @@ export async function requestPhoneCode(phone: string): Promise<boolean> {
   }
   }
 }
 }
 
 
-// 提交手机号 + 验证码登录:测试号走 /auth/test-phone,验证码由服务端校验
-// 服务端能力开关(test_phone_login / sms_login)由调用方根据 config 决定是否进入此函数
-export async function loginByPhoneCode(
-  phone: string,
-  code: string,
-  allowTestChannel: boolean,
-): Promise<Login> {
-  if (allowTestChannel && isTestPhone(phone)) {
-    return post<Login>('/auth/test-phone', { phone, code }, false);
-  }
+export async function loginByPhoneCode(phone: string, code: string): Promise<Login> {
   return post<Login>('/auth/phone-login', { phone, code }, false);
   return post<Login>('/auth/phone-login', { phone, code }, false);
-}
+}

+ 176 - 0
water-mini/tests/login-flow.test.mjs

@@ -0,0 +1,176 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { parse, compileScript } from '@vue/compiler-sfc';
+import ts from 'typescript';
+import * as vue from 'vue';
+
+const flowSource = await readFile(new URL('../src/utils/login-flow.ts', import.meta.url), 'utf8');
+const pageSource = await readFile(new URL('../src/pages/account/login.vue', import.meta.url), 'utf8');
+const { descriptor } = parse(pageSource, { filename: 'login.vue' });
+const script = compileScript(descriptor, { id: 'login-test' });
+
+function evaluate(source, dependencies, runtime = {}) {
+  const { outputText } = ts.transpileModule(source, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
+  });
+  const exports = {};
+  new Function('require', 'exports', 'setInterval', 'clearInterval', 'uni', outputText)(
+    (name) => {
+      if (name in dependencies) return dependencies[name];
+      throw new Error('Unexpected dependency: ' + name);
+    },
+    exports,
+    runtime.setInterval,
+    runtime.clearInterval,
+    runtime.uni,
+  );
+  return exports;
+}
+
+function fixture(capabilities = { sms_login: true, test_phone_login: true }) {
+  const posts = [], messages = [], timers = new Map(), accepted = [], navigations = [];
+  let timerId = 0;
+  let unmount;
+  const client = {
+    post: (path, payload, authenticated) =>
+      new Promise((resolve, reject) => posts.push({ path, payload, authenticated, resolve, reject })),
+  };
+  const format = { toast: (message) => messages.push(message), go() {} };
+  const flow = evaluate(flowSource, { '../api/client': client, './format': format });
+  const state = evaluate(script.content, {
+    vue: { ...vue, onUnmounted: (callback) => { unmount = callback; } },
+    '@dcloudio/uni-app': { onShow() {} },
+    '../../stores/session': {
+      session: vue.reactive({ configuration: { capabilities } }),
+      loadConfiguration() {},
+      acceptLogin: (login) => accepted.push(login),
+      accountPage: () => '/pages/home/index',
+    },
+    '../../api/client': client,
+    '../../utils/format': format,
+    '../../utils/login-flow': flow,
+  }, {
+    setInterval: (callback) => { timers.set(++timerId, callback); return timerId; },
+    clearInterval: (id) => timers.delete(id),
+    uni: { reLaunch: (options) => navigations.push(options.url) },
+  }).default.setup({}, { expose() {} });
+  return { ...flow, state, posts, messages, timers, accepted, navigations, unmount };
+}
+
+test('every phone, including former test numbers, requests a real SMS and uses SMS login', async () => {
+  for (const phone of ['13800138000', '13012345678', '13012345679', '13012345680']) {
+    const app = fixture();
+    const send = app.requestPhoneCode(phone);
+    assert.equal(app.posts.length, 1, 'must not fake sending for test numbers');
+    assert.equal(app.posts[0].path, '/auth/send-sms');
+    assert.deepEqual(app.posts[0].payload, { phone });
+    assert.equal(app.posts[0].authenticated, false);
+    app.posts[0].resolve({ sent: true, expires_in: 300 });
+    assert.equal(await send, true);
+    const login = app.loginByPhoneCode(phone, '728415');
+    assert.equal(app.posts[1].path, '/auth/phone-login');
+    assert.deepEqual(app.posts[1].payload, { phone, code: '728415' });
+    assert.equal(app.posts[1].authenticated, false);
+    app.posts[1].resolve({ user_id: '42' });
+    assert.equal((await login).user_id, '42');
+  }
+});
+
+test('get code works before agreement, serializes taps, and starts countdown only after success', async () => {
+  const app = fixture();
+  app.state.phone.value = '13800138000';
+  app.state.phoneCode.value = '123456';
+  assert.equal(app.state.agreed.value, false);
+  const send = app.state.sendCode();
+  await app.state.sendCode();
+  assert.equal(app.posts.length, 1);
+  assert.equal(app.state.sendingCode.value, true);
+  assert.equal(app.state.codeCountdown.value, 0);
+  app.posts[0].resolve({ sent: true, expires_in: 300 });
+  await send;
+  assert.equal(app.state.sendingCode.value, false);
+  assert.equal(app.state.phoneCode.value, '');
+  assert.equal(app.state.codeCountdown.value, 60);
+  assert.match(app.messages.at(-1), /查看手机短信/);
+  await app.state.sendCode();
+  assert.equal(app.posts.length, 1, 'countdown prevents resending');
+  for (let i = 0; i < 60; i++) [...app.timers.values()].forEach((tick) => tick());
+  assert.equal(app.state.codeCountdown.value, 0);
+  assert.equal(app.timers.size, 0);
+  const resend = app.state.sendCode();
+  assert.equal(app.posts.length, 2);
+  app.posts[1].resolve({ sent: true });
+  await resend;
+  app.unmount();
+  assert.equal(app.timers.size, 0);
+});
+
+test('failed SMS shows the server error, releases the button and permits retry without countdown', async () => {
+  const app = fixture();
+  app.state.phone.value = '13800138000';
+  const send = app.state.sendCode();
+  app.posts[0].reject(new Error('短信发送失败,请稍后再试'));
+  await send;
+  assert.match(app.messages.at(-1), /短信发送失败/);
+  assert.equal(app.state.sendingCode.value, false);
+  assert.equal(app.state.codeCountdown.value, 0);
+  assert.equal(app.timers.size, 0);
+  const retry = app.state.sendCode();
+  assert.equal(app.posts.length, 2);
+  app.posts[1].resolve({ sent: true });
+  await retry;
+  app.unmount();
+});
+
+test('test-login capability alone cannot enable random SMS login', async () => {
+  const app = fixture({ test_phone_login: true });
+  app.state.phone.value = '13012345678';
+  assert.equal(app.state.phoneLoginAvailable.value, false);
+  await app.state.sendCode();
+  assert.equal(app.posts.length, 0);
+  assert.match(app.messages.at(-1), /短信登录暂未开放/);
+});
+
+test('invalid numbers are explained without sending requests', async () => {
+  const app = fixture();
+  app.state.phone.value = '12345';
+  await app.state.sendCode();
+  assert.equal(app.posts.length, 0);
+  assert.match(app.messages.at(-1), /正确的手机号码/);
+});
+
+test('login still requires agreement, forwards the entered code and never uses test-phone', async () => {
+  const app = fixture();
+  app.state.phone.value = '13012345678';
+  app.state.phoneCode.value = '728415';
+  await app.state.loginByPhone();
+  assert.equal(app.posts.length, 0);
+  assert.match(app.messages.at(-1), /同意服务说明/);
+  app.state.agreed.value = true;
+  const login = app.state.loginByPhone();
+  await app.state.loginByPhone();
+  assert.equal(app.posts.length, 1);
+  assert.equal(app.posts[0].path, '/auth/phone-login');
+  assert.deepEqual(app.posts[0].payload, { phone: '13012345678', code: '728415' });
+  const session = { user_id: '42', access_token: 'token', identities: [] };
+  app.posts[0].resolve(session);
+  await login;
+  assert.deepEqual(app.accepted, [session]);
+  assert.deepEqual(app.navigations, ['/pages/home/index']);
+  assert.equal(app.state.busy.value, false);
+});
+
+test('wrong code is reported without accepting a session or navigating away', async () => {
+  const app = fixture();
+  app.state.phone.value = '13800138000';
+  app.state.phoneCode.value = '123456';
+  app.state.agreed.value = true;
+  const login = app.state.loginByPhone();
+  app.posts[0].reject(new Error('验证码错误'));
+  await login;
+  assert.match(app.messages.at(-1), /验证码错误/);
+  assert.equal(app.accepted.length, 0);
+  assert.equal(app.navigations.length, 0);
+  assert.equal(app.state.busy.value, false);
+});

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff