fix: some
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 2m59s

This commit is contained in:
luo
2026-10-13 14:53:19 +08:00
parent 77b0e25011
commit da95a914e3
31 changed files with 1702 additions and 116 deletions

View File

@@ -1,4 +1,7 @@
import request from '@/utils/request.js';
import { useAssetStore } from '@/store/asset.js';
import { useUserStore } from '@/store/user.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
export const normalizeAssetInfo = (data = {}) => ({
...data,
@@ -56,11 +59,23 @@ export const hasActiveOrPendingPackage = async (identifier) => {
export const assetApi = {
getInfo(identifier) {
const context = getSessionContext();
const userStore = useUserStore();
const phoneRevision = userStore.state.phoneRevision;
return request({
url: '/api/c/v1/asset/info',
method: 'GET',
data: { identifier }
}).then(normalizeAssetInfo);
}).then((response) => {
const data = normalizeAssetInfo(response);
if (identifier === context.identifier && isCurrentSession(context)) {
useAssetStore().receiveExpiryReminder(context, data.expiry_reminder);
if (phoneRevision === userStore.state.phoneRevision && typeof data.bound_phone === 'string') {
userStore.setCustomerPhone(data.bound_phone);
}
}
return data;
});
},
getPackageHistory(identifier, page, page_size, params = {}) {

View File

@@ -30,7 +30,8 @@ export const authApi = {
return request({
url: '/api/c/v1/auth/bind-phone',
method: 'POST',
data: { phone, code }
data: { phone, code },
showError: false
});
},

View File

@@ -31,7 +31,7 @@ export const notificationApi = {
markAllRead() {
return request({
url: '/api/c/v1/notifications/read-all',
method: 'PUT'
method: 'POST'
});
}
};

View File

@@ -18,7 +18,7 @@
<view class="info-row flex-row-sb">
<view class="info-label">
<view class="subtitle" v-if="isDevice">IMEI:{{ deviceInfo.imei || '-' }}</view>
<view class="subtitle" v-else>手机号:{{ deviceInfo.bound_phone || '-' }}</view>
<view class="subtitle" v-else>客户手机号:{{ deviceInfo.bound_phone || '-' }}</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,31 @@
<template>
<view class="card expiry-banner" :class="`expiry-${reminder.level}`" role="status">
<view class="banner-heading">
<view class="banner-title">{{ reminder.level_name || '套餐临期提醒' }}</view>
<button class="banner-close" aria-label="关闭临期提醒" @tap="$emit('close')">关闭</button>
</view>
<view class="banner-body">当前资产套餐剩余 {{ reminder.remaining_days }} 天,请及时续费。</view>
<view class="banner-expiry">预计到期:{{ formatShanghaiExpiry(reminder.expires_at) }}</view>
</view>
</template>
<script setup>
import { formatShanghaiExpiry } from '@/utils/expiry-reminder.js';
defineProps({ reminder: { type: Object, required: true } });
defineEmits(['close']);
</script>
<style scoped lang="scss">
.expiry-banner {
border-left: 8rpx solid var(--reminder-color);
.banner-heading { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.banner-title { color: var(--reminder-color); font-size: 30rpx; font-weight: 600; }
.banner-close { margin: 0; padding: 8rpx 16rpx; font-size: 24rpx; line-height: 1.5; color: var(--text-secondary); background: var(--gray-100); }
.banner-close::after { border: none; }
.banner-body { margin-top: 12rpx; font-size: 28rpx; line-height: 1.6; color: var(--text-primary); }
.banner-expiry { margin-top: 8rpx; font-size: 24rpx; color: var(--text-secondary); }
}
.expiry-red { --reminder-color: var(--danger); }
.expiry-purple { --reminder-color: var(--purple); }
.expiry-pink { --reminder-color: var(--pink); }
</style>

View File

@@ -63,8 +63,8 @@
<view class="function-icon">
<image class="function-image icon-bind-phone" src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
</view>
<view :class="['function-name', alreadyBindPhone ? 'text-primary' : 'text-danger']">
{{ alreadyBindPhone ? '已绑定' : '未绑定' }}
<view :class="['function-name', assetPhoneBound === true && customerPhone ? 'text-primary' : '']">
{{ bindingLabel }}
</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'change-phone')" role="button"
@@ -123,9 +123,13 @@
type: String,
default: ''
},
alreadyBindPhone: {
assetPhoneBound: {
type: Boolean,
default: false
default: null
},
customerPhone: {
type: String,
default: null
},
isDevice: {
type: Boolean,
@@ -151,9 +155,15 @@
};
const walletText = computed(() => `钱包 (${formatWalletBalance(props.walletBalance)})`);
const bindingLabel = computed(() => {
if (props.customerPhone === '') return '绑定客户号';
if (props.assetPhoneBound === false) return '绑定资产号';
if (props.assetPhoneBound === true && props.customerPhone) return '资产已绑定';
return '绑定手机号';
});
const handleBindPhone = () => {
if (props.alreadyBindPhone) {
if (props.assetPhoneBound === true && props.customerPhone) {
return;
}
emit('enter', 'bind');

View File

@@ -0,0 +1,335 @@
# 第 1 批(8-9 月迭代缺陷修复)前端对接说明
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-12 |
| 范围 | 分支 `Iteration/8-11` 上第 1 批 12 项(M1~M6、M8、M9、M12~M14、M16、M23、F1) |
| 用途 | 前端工程师对照修改:哪些接口变了、怎么变的、前端要做什么 |
| 接口细节以 | `docs/admin-openapi.yaml`(已重新生成)为准;本文来自代码与该文档的核对 |
| 环境 | 已部署测试环境,未上生产 |
> 路径前缀:后台接口为 `/api/admin/...`,H5(C 端)接口为 `/api/c/v1/...`。
> 所有改动都向后兼容:只新增字段 / 枚举值,**唯一的删除字段是 M16 的 `real_data_mb`**;唯一的校验收紧是密码规则与 CSV 表头。
---
## 一、前端必须改动的清单(速查)
| # | 项 | 端 | 动作 | 优先级 |
|---|---|---|---|---|
| 1 | M3 订单详情/列表 | 后台 | 加 3 个字段;详情加支付单列表 | 必改 |
| 2 | M8、M14 新状态「审批提交失败」 | 后台 + H5 | 5+1 个业务的状态枚举、筛选、标签、按钮显隐 | 必改 |
| 3 | M1 密码规则 | 后台 + H5 | 7 处设置密码的页面校验与提示文案 | 必改 |
| 4 | M12 换货被拒 | 后台 | 读取错误响应 `data.phone` 拼接提示 | 必改 |
| 5 | F1 绑定手机号 | H5 | 登录响应新增 2 个布尔字段,绑定流程判断改变 | 必改 |
| 6 | M9 临期提醒 | H5 | 资产详情新增 `expiry_reminder` 弹窗;通知中心已按资产过滤 | 必改 |
| 7 | M16 预警页面 | 后台 | 规则页去掉流量额度;预警列表加阈值流量 | 必改 |
| 8 | M2 批量换绑业务员 | 后台 | CSV 模板表头改名(若前端内置模板/文案) | 视情况 |
| 9 | M23 设备导入 | 后台 | 导入说明文案:IMEI 必填 | 文案 |
| 10 | M4~M6 导出补列 | — | 前端无需改动 | 无 |
---
## 二、逐项说明
### 1. M3 后台订单显示 C 端在线支付信息
**涉及接口**
| 方法 | 路径 | 变化 |
|---|---|---|
| GET | `/api/admin/orders` | 每条订单新增 3 个字段 |
| POST | `/api/admin/orders` | 返回体同为 `OrderResponse`,同样带 3 个字段 |
| GET | `/api/admin/orders/{id}` | 3 个字段 + 新增 `payments` 数组(响应类型变为 `OrderDetailResponse`) |
**新增字段(均始终返回,无值为空串)**
| 字段 | 类型 | 说明 |
|---|---|---|
| `third_party_trade_no` | string | 交易流水号 |
| `payment_channel` | string | 支付渠道:`wechat` 微信直连 v3 / `wechat_v2` 微信直连 v2 / `fuiou` 富友 |
| `payment_merchant_name` | string | 收款商户名称 |
- 只有「个人客户 + 微信或支付宝 + 已支付(含之后已退款)」的订单有值;代理购买、钱包、线下、未支付订单一律为空串。
**详情新增 `payments`(订单详情专有,列表不返回)**
仅个人客户微信/支付宝订单有数据,其余为 `[]`;按创建时间倒序(最新在前)。每项:
| 字段 | 说明 |
|---|---|
| `payment_no` | 支付单号 |
| `payment_method` | `wechat` / `alipay` |
| `payment_channel` | 同上 |
| `payment_merchant_name` | 商户名称 |
| `third_party_trade_no` | 交易流水号(未支付为空串) |
| `amount` | 金额(分) |
| `status` / `status_name` | 0 待支付 / 1 已支付 / 2 已失败 / 3 已退款 |
| `created_at` / `paid_at` / `expire_at` | 创建 / 支付 / 过期时间(后两者可为 null) |
**前端要做**:订单列表、详情加三个字段列;详情页加「支付记录」表格(可看出「付了钱没成功」、重复支付、换过支付方式)。
---
### 2. M8、M14 新状态「审批提交失败」(影响 6 个业务)
企微审批没能提交成功时,业务单不再卡在「审批中」,而是变为**终态**「审批提交失败」。各业务新增状态值如下(**数值各业务不同,不要共用一个枚举**):
| 业务 | 新状态值 | 字段 | 涉及接口 | 重新发起方式 |
|---|:-:|---|---|---|
| 核销申请(员工代收款) | **4** | `status` / `application_status` | `GET/POST /api/admin/employee-collection-applications`、`GET/PUT /api/admin/employee-collection-applications/{id}`、`GET /api/admin/employee-collection-bills/{id}` | `PUT /api/admin/employee-collection-applications/{id}`(重新提交,生成新审批) |
| 退款 | **7** | `status` | `GET/POST /api/admin/refunds`、`GET /api/admin/refunds/{id}`、`/{id}/trigger-approval`、`/{id}/offline-settlement` | `POST /api/admin/refunds/{id}/resubmit` |
| 扫码注册代理 | **3** | `status` | `GET /api/admin/agent-distribution-registrations`、`.../{id}`;H5 `POST /api/c/v1/agent-distribution-registrations` | 申请人重新申请(走原申请入口,生成新的注册申请) |
| 线下充值(代理自充) | **7** | `status` | `GET/POST /api/admin/agent-recharges`、`.../{id}`、`/{id}/offline-pay`、`/{id}/payment-status`、`/{id}/trigger-approval` | 重新发起(后端没有对原单的单独接口,前端调 `POST /api/admin/agent-recharges` 新建线下充值单) |
| 佣金提现 | **5** | `status` | `GET /api/admin/commission/withdrawal-requests`、`/{id}/approve`、`/{id}/reject`;`GET /api/admin/shops/{shop_id}/withdrawal-requests` | `PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}`(重新提交,按新金额重新冻结) |
| 提现资料资格 | **4** | `status` | `GET/POST /api/admin/shops/{shop_id}/withdrawal-qualifications` | 重新提交资料版本 |
**列表筛选参数的取值范围同步放宽**:
- 退款 `status` 查询:`min=1,max=6` → `max=7`
- 佣金提现 `status`:`max=4` → `max=5`
- 核销 `status`:`oneof 0 1 2 3` → 加 `4`
- 扫码注册代理 `status`:`oneof 0 1 2` → 加 `3`
- 提现资料资格 `status`:`oneof 0 1 2 3` → 加 `4`
- 线下充值 `status` 过滤:说明增加 `7`
**行为变化(前端按钮显隐)**
- 状态为「审批提交失败」的单据**不能再点「恢复审批」**(接口会拒绝):
- `POST /api/admin/refunds/{id}/recover-approval`
- `POST /api/admin/employee-collection-applications/{id}/recover-approval`
- `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`
只显示「重新发起」。
- 「提交结果未知」不是失败,仍显示「恢复审批」,由后台任务确认。
- 佣金提现变为 5 后,**冻结金额已自动释放**;重新发起时重新冻结。
- 核销申请:释放预占金额,账单可再次核销。
**核销申请额外变化(M8)**:`approval_submission_status` 以前恒为空,现在返回真实值:
`0 待提交 / 1 提交中 / 2 已提交 / 3 提交失败 / 4 结果未知`(无审批上下文为 0)。列表与详情都有。
**前端要做**:
1. 6 处状态枚举/标签/颜色补充新值(建议红色或灰色,标注「终态」);
2. 列表筛选下拉加该选项;
3. 操作按钮:该状态隐藏「恢复审批」,保留「重新发起」;
4. 核销申请列表/详情展示「审批提交状态」。
---
### 3. M1 登录密码统一规则
**新规则(后端强制)**:长度 **8~12 位**;大写字母、小写字母、数字、特殊符号**至少包含三类**;不能有空格和中文。
例:`Abc12345` ✅,`abcd1234` ❌(只有两类)。
**涉及的 7 个入口**
| 场景 | 方法与路径 | 字段 | 旧规则 → 新规则 |
|---|---|---|---|
| 本人修改密码 | `PUT /api/auth/password` | `new_password` | 8~32 → 8~12 + 三类 |
| 创建后台账号 | `POST /api/admin/accounts` | `password` | 8~32 → 8~12 + 三类 |
| 修改账号 | `PUT /api/admin/accounts/{id}` | `password`(可选) | 同上 |
| 修改账号密码 | `PUT /api/admin/accounts/{id}/password` | `new_password` | 同上 |
| 创建店铺(含初始账号) | `POST /api/admin/shops` | `init_password` | 8~32 → 8~12 + 三类 |
| 创建企业客户 | `POST /api/admin/enterprises` | `password` | 6~20 → 8~12 + 三类 |
| 修改企业密码 | `PUT /api/admin/enterprises/{id}/password` | `password` | 6~20 → 8~12 + 三类 |
| 扫码注册代理(H5) | `POST /api/c/v1/agent-distribution-registrations` | `password` | 6~64 → 8~12 + 三类 |
**错误提示变化**:修改密码以前只返回「参数验证失败」,现在是字段级提示,直接展示 `message` 即可,例如:
- `修改密码参数不合法:新密码长度不能小于 8`
- `修改密码参数不合法:密码须包含大写字母、小写字母、数字、特殊符号中的至少三类`
**不变**:已在用的旧密码照常登录,不强制改;超级管理员操作密码、WiFi 密码不受影响。
**前端要做**:上述页面的前端校验、输入框 `maxlength`、占位/提示文案同步为新规则;错误提示直接显示后端返回的 `message`。
---
### 4. M12 换货:新资产已绑手机号时被拒
**涉及接口**
| 方法 | 路径 | 场景 |
|---|---|---|
| POST | `/api/admin/exchanges` | 创建换货单 |
| POST | `/api/admin/exchanges/{id}/complete` | 物流换货确认完成 |
**变化**
- 旧逻辑「新资产被人扫过码就拒绝」**取消**。
- 新逻辑:新资产存在**有效手机号关联**才拒绝,错误码 **1202**,`message`:
`新资产已绑定手机号,请先在后台解除该资产的手机号绑定,再重新提交换货申请`
- 完整手机号放在错误响应的 **`data.phone`**(message 与日志中都不含号码)。
- `complete` 接口被拒时,换货单停在「已发货待确认」,解除后可重新确认。
**响应示例**
```json
{
"code": 1202,
"msg": "新资产已绑定手机号,请先在后台解除该资产的手机号绑定,再重新提交换货申请",
"data": { "phone": "138xxxxxxxx" }
}
```
**前端要做**:捕获 1202 时读取 `data.phone`,弹窗提示「新资产已绑定手机号 {phone},请先到『手机号资产关联』解除后再提交」,并可跳转/引导到解除入口。解除使用已有接口(无新增):
- `DELETE /api/admin/phone-asset-associations/{id}`(单条)
- `POST /api/admin/phone-asset-associations/batch-unbind`(按资产批量)
- `POST /api/admin/phone-asset-associations/unbind-imports`(CSV)
- `GET /api/admin/phone-asset-associations`(查询)
**换货完成后的副作用(供前端提示参考)**:旧资产的手机号关联和客户绑定都被清空,原客户已打开的旧资产页面会失效,需重新扫码。
---
### 5. F1 一个资产只能绑一个手机号
**涉及接口(H5)**
| 方法 | 路径 | 变化 |
|---|---|---|
| POST | `/api/c/v1/auth/wechat-login` | 响应新增 2 个字段,`need_bind_phone` 语义改变 |
| POST | `/api/c/v1/auth/miniapp-login` | 同上 |
| POST | `/api/c/v1/auth/bind-phone` | 资产已绑其他号时返回 **1192** |
| POST | `/api/c/v1/auth/change-phone` | 只换客户手机号,不再连带改资产 |
**登录响应新增字段**
| 字段 | 说明 |
|---|---|
| `asset_phone_bound` | 当前资产是否已绑定手机号(不返回号码) |
| `asset_phone_is_own` | 资产绑定的号码是否为当前客户自己的号(不返回号码) |
| `need_bind_phone`(语义变更) | 强制绑定开关开启时:客户无手机号,**或**当前资产尚无有效手机号关联 → `true`;开关关闭恒为 `false`。仅前端提示用 |
**四种场景对应的前端流程**
| 客户 | 资产 | 前端应该 |
|---|---|---|
| 无号 | 无号 | 验证一次手机号,客户和资产都写入这个号 |
| 有号 | 无号 | 要求给资产绑号,可以和客户的号不同 |
| 无号 | 已有号 | 只要求客户绑自己的号,资产不动 |
| 有号 | 已有号 | 不用绑 |
**新错误**:`bind-phone` 资产已绑定其他号码时,返回 **1192**「该资产已绑定手机号,如需更换请联系管理员」(不含号码)。前端提示用户联系管理员,不要重试。手机号已被其他客户绑定的 **1184** 不变。
**前端要做**:绑定页根据 `asset_phone_bound` / `asset_phone_is_own` / `need_bind_phone` 决定是否展示绑定入口;处理 1192;客户换号后不要假设资产号码跟着变。
---
### 6. M9 套餐临期提醒:按资产、扫码进入时触发
**涉及接口(H5)**
| 方法 | 路径 | 变化 |
|---|---|---|
| GET | `/api/c/v1/asset/info` | 新增可选字段 `expiry_reminder` |
| GET | `/api/c/v1/notifications` | 临期提醒只返回当前资产的(入参不变) |
| GET | `/api/c/v1/notifications/unread-count` | 未读数只计当前资产的临期提醒 |
| POST | `/api/c/v1/notifications/read-all` | 其他资产的临期提醒保持未读 |
**`expiry_reminder`(可选,无则不返回该字段)**
| 字段 | 说明 |
|---|---|
| `remaining_days` | 剩余自然日(0~15,按上海时区) |
| `expires_at` | 预计最终到期时间 |
| `level` | `red`(0~3 天)/ `purple`(4~7 天)/ `pink`(8~15 天) |
| `level_name` | 等级中文名 |
**规则**:同一客户、同一资产、同一天(北京时间)**只返回一次**;返回的同时后端已写一条通知。当天再次进入不再返回。
**前端要做**:资产详情页判断到 `expiry_reminder` 存在时弹窗/横幅提醒;不要用旧的「每日推送的临期通知」做弹窗依据(已不再给个人客户发);通知中心无需改,后端已按当前资产过滤。
---
### 7. M16 真流量预警页面口径整理
| 方法 | 路径 | 变化 |
|---|---|---|
| GET | `/api/admin/package-traffic-alert-rules` | 规则列表**删除字段 `real_data_mb`** |
| GET | `/api/admin/package-traffic-alert-rules/{id}` | 规则详情**删除字段 `real_data_mb`** |
| GET | `/api/admin/package-traffic-alerts` | 预警列表新增 `threshold_mb` |
| GET | `/api/admin/package-traffic-alerts/{id}` | 预警详情新增 `threshold_mb` |
| 导出 | 预警导出任务 | 新增列「阈值流量」,放在**最后一列** |
- `threshold_mb`(float,保留两位小数)= 额度快照 × 阈值百分比 ÷ 100,口径为「主套餐 + 加油包」。
- `used_mb`、`limit_mb` 字段不变,说明改为「主套餐与加油包汇总」。
- 规则页现在只返回:`id`、`package_id`、`package_name`、`threshold_percent`、`enabled`、`enabled_name`、`remark`、`updated_at`。
**前端要做**:规则列表/详情去掉「套餐流量额度」列及相关引用(否则显示 `undefined`);预警列表/详情加「阈值流量(MB)」;已用/总量列标题可注明「含加油包」。
> 注意:`/api/admin/packages` 等套餐接口里的 `real_data_mb` 是套餐本身字段,**没有变化**,不要误删。
---
### 8. M2 批量换绑业务员 CSV 表头改名
| 方法 | 路径 |
|---|---|
| POST | `/api/admin/shops/business-owner-imports` |
- CSV 固定表头改为:`店铺编码`、`操作类型`、**`平台登录账号`**、`备注`(原为「业务员登录账号」)。
- **「平台登录账号」这一列只填手机号**;填用户名会失败,原因提示「请填写手机号」。
- 旧表头整批失败(提示表头不符),不做新旧并存。
- 失败原因分开:格式不对、手机号不存在、不是平台用户、账号已禁用、匹配到多个账号。
- 接口路径、请求体不变;逐行独立处理,成功行与失败行互不影响。
**前端要做**:如果前端内置/下载模板或有填写说明,改为新表头并注明「填写平台用户的登录手机号」;失败明细按后端返回的原因原样展示即可。
---
### 9. M23 设备导入 IMEI 必填
| 方法 | 路径 |
|---|---|
| POST | `/api/admin/devices/import` |
- 请求结构、模板列名/列数/列序**都不变**。
- `imei` 为空(含只填空格)的行**导入失败**,失败原因「设备IMEI(imei)不能为空」,出现在任务详情的 `failed_items`(带行号)。
- 同一文件内 IMEI 齐全的行照常导入;整份文件都失败时任务状态为「失败」。
- 只校验非空,不校验格式与重复。
**前端要做**:导入页说明文案标注「IMEI 必填」;任务详情照常展示 `failed_items`。
---
### 10. M4~M6 三类导出补列(前端无需改动)
| 导出 | 新增 |
|---|---|
| 订单导出 | 最后一列「操作人」 |
| IoT 卡导出 | 最后一列「套餐到期时间」(算不准留空) |
| 设备导出 | 流量表头注明「(MB)」,最后一列「设备合计(GB)」 |
只有**新建**的导出任务带新列,历史任务重新下载仍是旧表头,不会错位。
---
### 11. M13:企微拒绝/撤销/删除后退款单结束(后端修复,前端关注点)
**无接口字段变化**。修复后,企微侧「拒绝/撤销/删除」会让退款单正确变为「已拒绝」(status = 3),资产可再换货。
**前端关注**:
- 不支持在企微里「重新提交」(企微新审批单卡管不关联);已拒绝的退款单请在卡管用 `POST /api/admin/refunds/{id}/resubmit` 重新发起。
- 如页面有提示「可在企微重新提交」之类文案,请去掉。
---
## 三、新增 / 变化的错误码
| 码 | 接口 | 含义 | 前端处理 |
|---|---|---|---|
| 1202 | `POST /api/admin/exchanges`、`.../{id}/complete` | 新资产已绑定手机号 | 读 `data.phone`,引导去后台解除 |
| 1192 | `POST /api/c/v1/auth/bind-phone` | 资产已绑定其他手机号 | 提示联系管理员,不重试 |
| 参数不合法 | 密码相关 7 个入口 | 带字段级原因的 `message` | 直接展示 `message` |
---
## 四、联调自测建议
1. 订单:用 C 端微信已支付订单看详情,`payments` 有记录、三个字段与支付单一致;代理/钱包订单三个字段为空串、`payments=[]`。
2. 状态枚举:造一个提交失败的单据(缺必填控件映射可复现),检查 6 类业务列表、筛选、按钮显隐。
3. 密码:`Abc12345` 通过;`abcd1234`、`Abc1234`(7 位)、`Abc123456789X`(13 位)被拒且提示文案可读。
4. 换货:对已绑手机号的资产发起换货,应弹出含号码的提示;后台解除后重新提交成功。
5. H5:同一客户绑定两个临期资产,进入 A 只看到 A 的提醒,当天再进不再出现。
6. 预警:规则页无流量额度;预警列表有阈值流量。
## 五、不需要前端改动的内容
- C 端订单接口、H5 退款接口无变化。
- 预警判定逻辑、套餐接口(`real_data_mb` 在套餐里仍存在)无变化。
- M13 无字段变化;M4~M6 导出由后端新增列。

View File

@@ -0,0 +1,50 @@
# 第 1 批 H5 对接实施与验证记录
2026-10-13:客户端功能已实现。仅对接 `/api/c/v1`;不处理代理注册、密码规则和后台接口。真实微信授权、短信绑定及后端 A/B 资产通知范围尚需测试环境联调,未部署或提交代码。
## 已实现
- 客户手机号、资产是否绑号、号码归属、绑定提示分别保存;登录字段按当前令牌和资产隔离,退出/过期/切换时清理,旧 phoneBindStatus 不再作为权威状态。
- 四种绑定模式使用原 bind-phone 入参;资产首次绑号可输入与客户号不同的号码,1192 提示管理员并禁用当前提交,1184 保留后端错误。强制绑定成功仍要求手动重登。
- 客户换号只更新后端确认的客户号码,不改资产关联;归属事实失效,下一次真实登录重新确认。换号的原号来源不再放进 URL。
- asset/info 的共享返回入口接收一次性临期提醒,首页使用可关闭横幅。0 天正常显示,使用服务器等级和上海时区。强制绑定页不为预填请求 asset/info,避免提前消费提醒后跨重登丢失。
- 旧首页通知弹窗排除临期项,通知中心保留。全部已读改 POST,单条已读保持 PUT;成功后重新读取列表和计数,失败不乐观清空,延迟响应不能覆盖新的资产上下文。
## 验证证据
| 证据 | 结果 | 说明 |
|---|---|---|
| `npm run test:h5-integration` | 14/14 通过 | 实际 store/API 模块及页面 setup,使用 Vue 响应性;原生 API、生命周期和后端响应为模拟 |
| `npm run build:h5` | 通过 | uni-app H5 开发配置构建 |
| `npm run build:h5:production` | 通过 | uni-app H5 生产配置构建 |
| `openspec validate update-october-batch1-h5-integration --strict` | 通过 | 提案、需求和场景结构校验 |
| `git diff --check` | 通过 | 默认 Git 换行配置下未发现补丁空白错误;Windows 换行提示不影响构建 |
| 本地浏览器,390×844 | 通过 | 0 天红色横幅、上海到期时间、关闭、绑定客户号预填、编辑不同号码、1192 联系管理员弹窗及禁用提交,无页面运行错误 |
测试文件:[tests/october-h5.test.mjs](../../../tests/october-h5.test.mjs)。覆盖绑定模式/成功后的分类更新、登录提示开关、状态持久化/隔离、换号不迁移资产、1192/1184、强制绑定不提前消费提醒、共享接收/并发、0~15 天/上海边界、旧弹窗过滤、POST/PUT 请求方法、通知失败与切换、模拟 A/B 全部已读以及 401 状态清理。
本地渲染复验:先完成生产构建,再运行以下命令。使用独立浏览器会话和虚构号码,所有业务接口改写到本地模拟服务。
```powershell
node tests/mock-h5-preview.mjs
```
另开一个终端:
```powershell
agent-browser --session october-h5-ui --headed false --init-script ./tests/mock-h5-browser-init.js open http://127.0.0.1:48138/pages/index/index
agent-browser --session october-h5-ui set viewport 390 844
agent-browser --session october-h5-ui snapshot -i
agent-browser --session october-h5-ui close
```
模拟服务固定在 `127.0.0.1:48138`,init 脚本仅在该地址注入;检查后使用 Ctrl+C 停止服务。该模拟接口故意重复返回提醒用于检查前端防重复,不能证明后端每日一次规则。
## 仍需真实联调
1. 微信公众号真实登录应返回三个布尔字段;尤其验证客户无号/资产有号时只给客户绑定,以及客户已有号时资产可绑定不同号码。
2. 确认 asset/info.bound_phone 仍表示当前客户手机号,与登录布尔事实区分;换号后资产号码保持原值。若实际响应与对接文档冲突,先确认契约,不猜测接口。
3. 同客户 A/B 两个临期资产分别扫码进入,确认服务器每天只返回一次、临期列表/未读数仅计当前资产,以及 A 上 POST 全部已读不改变 B 的临期通知。
4. 完整回归真实支付、实名认证、套餐订购以及运营/风险换货候选弹窗。本次没有修改这些接口或候选组件,构建成功不替代真实业务验收。
完成结果已按实际验证范围回写 [tasks.md](tasks.md),真实联调项保留未勾选;不归档历史提案。

View File

@@ -0,0 +1,92 @@
## Context
项目为 Vue 3 / uni-app H5。`openspec/specs` 当前没有已归档基线,因此本提案在现有相关 capability 名称下使用 `ADDED Requirements` 建立本次需求,不对不存在的基线使用 `MODIFIED`。`phone-change-migration` 保留历史 capability 名称以便追踪,但行为明确改为“只更换客户手机号”,不表示仍支持迁移。
接口行为以 2026-10-12 的第 1 批对接说明和用户 2026-10-13 的答复为准。仓库未提供说明中引用的 `docs/admin-openapi.yaml`,不因此回退旧的换号或全部已读约定。
| 代码证据 | 问题 | 处理方向 |
|---|---|---|
| `pages/login/login.vue` 只读取 `need_bind_phone` | 登录的资产绑定事实未保存 | 保存三个布尔字段,按当前登录资产隔离 |
| `pages/index/index.vue` 用本地手机号优先覆盖 `bound_phone`,并据此计算 `alreadyBindPhone` | 客户手机号与资产关联混为一体,旧缓存可覆盖服务端 | 拆分客户手机号、资产绑定/归属、是否需要提示 |
| `pages/bind/bind.vue` 成功后无条件 `setPhoneBindStatus` | 只给客户绑号也可能被记为资产换号 | 按本次绑定对象刷新,不推断资产号码 |
| `pages/change-phone/change-phone.vue` 把 `newPhone` 写入资产缓存 | 与新文档“只换客户手机号”冲突 | 仅更新客户手机号,保留资产关联 |
| 首页、TrafficCard、绑定页等分别请求 `asset/info` | 一次性提醒可能先被非展示调用方取走 | 在 API 返回入口集中接收,再由资产信息区展示 |
| 首页 `loadUnreadNotifications` 展示所有未读项 | 仍以旧临期通知触发弹窗 | 从旧弹窗来源排除临期项,通知中心不删除 |
| `api/modules/notification.js` 的 `markAllRead` 使用 PUT | 与新文档方法不一致 | 明确切换 POST,不影响 `markRead` 的 PUT |
## Goals / Non-Goals
- Goals:正确区分客户与资产绑定、支持文档四种流程、客户换号不改变资产关联、一次性临期提醒不丢失/不重复、通知全部已读方法正确。
- Non-Goals:代理注册及密码规则、后台接口、全局通知重构、新增登录方式、重新设计支付或实名流程。
## Decisions
### 1. 分离客户与资产手机号状态
`asset_phone_bound` 只表示当前资产存在有效手机号关联,`asset_phone_is_own` 只表示该关联是否属于当前客户;二者不提供号码。`need_bind_phone` 是后端计算的提示开关,不能由前端用手机号为空、归属为 false 或本地缓存重新计算。
现有八月份接口定义 `docs/产品迭代8月份/11.md` 将 `asset/info.bound_phone` 描述为“当前登录用户绑定的手机号”。新对接说明声明字段为兼容性新增、未改变该字段,故保留其客户手机号用途:可作为绑定预填和更换客户手机号的原号来源,但不能作为资产关联是否存在的证据。若实施联调的实际响应与这一未变字段约定冲突,先向用户说明并确认,不猜测新增字段或号码。
在用户状态中记录当前登录资产的三个布尔字段;在退出、切换账号或资产时清理/更换。原 `phoneBindStatus` 不再作为服务端资产事实或客户原手机号的权威来源,不能让历史的 `bound: true` 绕过当前状态,也不能跨客户复用。未获取新字段时保持“未知”,不把缺失默认为已绑定,也不新增 OAuth 自动重登来刷新事实。
### 2. 按四种场景处理绑定
| 客户手机号 | 资产手机号 | 表单与成功后的处理 |
|---|---|---|
| 无 | 无 | 验证一次输入号码,由后端同时写入客户与资产 |
| 有 | 无 | 提示“给当前资产绑定手机号”;客户号码可预填且可编辑,允许提交不同号码,不据此改客户手机号 |
| 无 | 有 | 提示“绑定客户手机号”;仍使用既有 bind-phone 请求,成功后只更新客户状态,不覆盖资产关联 |
| 有 | 有 | 不要求绑定;资产号码属于他人也不要求改绑或暴露号码 |
是否触发登录后的绑定提示仍取决于 `need_bind_phone`;关闭强制开关时不产生自动跳转。首页功能入口分别展示资产绑定状态与客户换号能力,不能因客户已有手机号就禁用资产首次绑号。
对于 `1192`,展示联系管理员的明确提示,停止再次提交当前资产改绑请求;不自动重试、不换接口解除、不显示资产号码。`1184` 等其他错误按返回消息提示,失败不更新状态。请求仍使用 `{ phone, code }`,不凭空新增绑定对象参数。
保留强制绑定完成后回登录页、保留 identifier 并由用户手动重登的现有行为;普通绑定成功返回上一页。成功后的状态更新必须对应本次实际绑定对象,不能用“请求成功”统一代表客户号和资产号均已修改。
实施时避免强制绑定页为预填发起 asset/info:该页优先使用已有客户手机号;尚无客户号码时保留可输入表单,由后端判定实际绑定对象。`asset_phone_bound=true` 且 `need_bind_phone=true` 明确对应“只给客户绑号”。普通绑定页继续使用 asset/info 预填。这保证强制绑定后的手动重登不会丢弃已被预填请求消费的每日提醒,也不需要猜测跨令牌的客户身份。
### 3. 客户换号不迁移资产
保留原/新手机号和验证码的现有表单以及 `change_phone_old`、`change_phone_new` 场景。成功后只更新服务端确认的客户手机号,不调用 `setPhoneBindStatus(identifier, newPhone)`,不显示“全部资产换绑成功”或旧事务迁移文案。
原资产仍可保持已绑定,但其号码可能不再属于客户;例如原 `asset_phone_is_own=true` 在客户改成不同号码后可能为 false。若响应不提供更新后的归属事实,不能把旧 true 留作最新事实,应失效该归属状态,等下一次真实登录响应确认;不借此要求资产改绑。
### 4. 一次性临期提醒使用横幅及共享接收
首页就是当前 H5 的资产信息展示页,本次不新增资产详情路由。选择可关闭横幅展示临期信息,避免与已有风险换货、运营及支付弹窗争抢显示。
原样保留 `expiry_reminder.remaining_days`、`expires_at`、`level`、`level_name`;使用服务端等级映射 red / purple / pink 的样式,保留文本提示,不能只靠颜色传达紧急程度。`remaining_days=0` 必须正常展示。日期展示遵循 Asia/Shanghai,不能用客户端当前时区重算剩余自然日或等级。
`assetApi.getInfo` 成功返回处集中接收非空提醒并交给共享状态,同次资产进入期间的并发响应不允许用“未返回字段”的响应覆盖已经接收的提醒。共享状态隔离当前客户上下文与 identifier,避免切换资产/退出后显示旧资产或他人的提醒。提醒到达时若资产信息区尚不可见,应先暂存,待对应资产可见时再展示,不能静默丢弃。
后端负责“客户 + 资产 + 上海自然日”的每日一次;前端只防止同一次接收被多个组件重复展示,不增加每日定时推送、补发请求或长期缓存复播。关闭/已展示的提醒在当天后续无字段响应时不得重新出现。审核所有 `asset/info` 调用方,特别是绑定前预填及首页 TrafficCard 的请求顺序;账户身份无法确定的跨重登暂存不得当作可信提醒直接展示,若现有接口无法保证安全传递,应在实施前向用户说明具体阻碍。
横幅展示/关闭不自动标记通知已读:提醒不含 notification_id,不能按标题、日期或列表位置猜测 ID,也不能调用 read-all 消掉新通知。临期通知继续由通知中心管理阅读状态。
### 5. 通知仅调整临期来源与全部已读方法
从首页旧通知弹窗来源排除临期项,沿用既有 `category=expiry` 识别方式;不删除通知中心临期项,不改变其他未读通知的顺序和展示。`popup-candidates` 的风险换货/运营链路不受影响。
列表、未读数保持原入参,不增加文档未定义的 identifier/asset_id;后端基于当前认证资产过滤临期通知,并非所有通知都只属于当前资产。切换资产后刷新列表和计数,不将 A 的未读结果拼接到 B。
全部已读使用 POST,保留通知中心主动触发;失败不提前清除未读状态。成功后以刷新结果对齐本地列表/未读数,不能把其他资产的临期通知置已读。单条已读继续 PUT。风险换货、运营弹窗及临期横幅均不得自动调用全部已读。
## Risks / Trade-offs
- 一次性提醒在任何 asset/info 读取时就可能被后端消费:集中接收并回归首页并发、绑定预填、其他页面进入,不能只给首页 getInfo 的响应加判断。
- 客户号码与资产号码不同:服务端布尔事实和客户号分离,历史本地缓存不再覆盖服务端。
- 没有最新 YAML:按已确认的新文档实施;如实际契约缺字段或与四场景不符,提供响应结构差异(不含真实号码/令牌)并先询问用户。
- 尚未归档的旧提案仍含相反规则:本提案显式声明覆盖范围,不顺带改写或归档历史提案。
## Migration Plan
1. 提案批准后,先完成状态拆分及旧缓存失效,再调整绑定/换号行为。
2. 加入临期提醒共享接收和横幅,排除首页旧临期弹窗来源。
3. 切换全部已读为 POST,回归当前资产的列表与未读数。
4. 完成任务中的场景验证及 H5 构建,记录实际结果;不以静态构建代替真实接口联调。
5. 需要回退时只回退本次前端改动;服务端新契约不变,不能恢复“客户换号即资产迁移”的旧假设作为兼容策略。
## Open Questions
提案范围和优先契约已由用户确认,无需再次决定代理注册或 read-all 方法。实施前仍应检查接口是否实际提供所需字段及一次性提醒跨页面传递条件;只在发现具体矛盾时报告并询问,不擅自扩展接口。

View File

@@ -0,0 +1,56 @@
# Change: 对接十月份第 1 批 H5 接口变更
本提案仅覆盖 `/api/c/v1` 的现有 H5 功能。用户于 2026-10-13 批准执行;实现和验证结果见 [IMPLEMENTATION.md](IMPLEMENTATION.md)。
## Why
[第 1 批前端对接说明](../../../docs/10月份修改/第1批-8-9月迭代前端对接说明.md)中的 F1、M9 改变了手机号关联和临期提醒的行为。当前代码仍混用客户手机号与资产绑定状态、在客户换号后覆盖资产本地手机号,并从未读通知触发临期弹窗,可能出现错误的绑定提示和重复提醒。
## What Changes
- 接收并保存公众号登录返回的 `asset_phone_bound`、`asset_phone_is_own`、`need_bind_phone`,区分客户手机号与当前资产手机号关联,不能再用一个“已绑定”缓存表达两者。
- 按 F1 的四种场景调整绑定页文案、绑定入口及提交后的状态处理;客户已有手机号、资产无号时,允许给资产绑定不同号码。
- 对 `bind-phone` 的 `1192` 提示“该资产已绑定手机号,如需更换请联系管理员”,不提供重试当前绑定请求的操作;保留 `1184` 的既有错误处理。
- **BREAKING(替代旧前端假设)**:`change-phone` 只修改客户手机号,不更新任何资产手机号关联;停止将新客户手机号写入资产绑定缓存。
- 接收 `asset/info.expiry_reminder`,在首页资产信息区展示可关闭横幅,展示后端提供的剩余天数、到期时间和等级;无字段时不自行计算或补发提醒。
- 统一接收各 `asset/info` 调用返回的一次性提醒,防止首页子组件或其他调用方先获得字段后将其丢弃;不为提醒新增轮询或额外请求。
- 首页旧未读通知弹窗不再以临期通知作为弹窗依据;临期通知仍保留在通知中心,其他通知和既有运营/风险换货弹窗不变。
- **BREAKING(请求方法切换)**:全部已读改为 `POST /api/c/v1/notifications/read-all`,不再发送旧 `PUT` 或自动回退;通知列表和未读数沿用现有入参,由后端过滤当前资产的临期通知。
## Scope and confirmed decisions
用户于 2026-10-13 确认:不处理代理注册;手机号行为以当前对接说明为准;全部已读以新文档的 `POST` 为准。
| 接口 | 本次处理 | 不变部分 |
|---|---|---|
| `POST /api/c/v1/auth/wechat-login` | 消费两个资产绑定布尔字段及新的绑定提示语义 | OAuth、令牌、资产校验流程 |
| `POST /api/c/v1/auth/bind-phone` | 四种绑定场景、1192、成功后的分类状态更新 | `{ phone, code }`、验证码接口与场景 |
| `POST /api/c/v1/auth/change-phone` | 客户换号,不再假设资产关联迁移 | 原/新手机号及验证码表单、接口入参 |
| `GET /api/c/v1/asset/info` | 接收一次性 `expiry_reminder`,区分手机号用途 | 原资产、套餐、实名、支付字段 |
| `GET /api/c/v1/notifications` | 回归当前资产临期通知的可见性 | 分页、排序、通知中心展示 |
| `GET /api/c/v1/notifications/unread-count` | 使用后端当前上下文计数 | 原请求参数、未读入口 |
| `POST /api/c/v1/notifications/read-all` | 从旧 PUT 切换为 POST | 用户主动点击“全部已读”的入口 |
明确排除:
- 代理注册页面、接口封装、密码新规则及注册 `status=3`。
- 所有 `/api/admin`、`/api/auth` 的变更,以及后台订单、审批、换货、导入、预警、导出功能。
- 新增小程序登录入口;`miniapp-login` 的同名字段仅作为后端共用契约背景,本 H5 继续使用公众号登录。
- 修改 C 端订单、退款接口、WiFi 密码规则、支付路由及实名流程。
- 新增后端接口、手机号关联解除/迁移接口,或从布尔字段推测资产实际号码。
## Impact
- Affected specs: `index-phone-bind-gate`、`phone-bind-association-prefill`、`phone-change-migration`、`c-notification-reminders`、`personal-notifications`。
- Affected code: `pages/login/login.vue`、`pages/index/index.vue`、`pages/bind/bind.vue`、`pages/change-phone/change-phone.vue`、`components/FunctionCard.vue`、`components/DeviceStatusCard.vue`、`api/modules/asset.js`、`api/modules/notification.js`、`store/user.js`、`store/asset.js`、`utils/phone-bind.js`。
- 检查但不预设必须修改:`components/TrafficCard.vue`、`components/RenewalPaymentPopup.vue`、其他 `assetApi.getInfo` 调用方及通知中心的刷新逻辑。
- 不增加依赖,不执行后端或数据迁移。
## Existing change conflicts
- [update-phone-bind-association-flow](../update-phone-bind-association-flow/proposal.md):其“不同于客户主号则拒绝”“换号迁移全部资产关联”“用 bound_phone 判断资产关联”的旧约定,被本提案的 F1 约定替代;不覆盖其历史文件及完成标记。
- [update-july-h5-c-iteration](../update-july-h5-c-iteration/proposal.md):仅替代临期通知作为首页弹窗来源的约定,保留通知中心、其他通知和套餐到期信息展示。
- [add-personal-notifications](../add-personal-notifications/proposal.md):仅切换全部已读方法及遵循当前资产临期通知范围,不把单条已读的 PUT 一并改为 POST。
- [add-h5-popup-candidate-flow](../add-h5-popup-candidate-flow/proposal.md):保留候选弹窗与单条已读链路;临期横幅独立展示,不调用全部已读来关闭任何弹窗。
技术选择、字段依据及风险见 [design.md](design.md),执行及验收清单见 [tasks.md](tasks.md)。

View File

@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Expiry reminders SHALL use the optional asset-info response
H5 SHALL 仅以 `GET /api/c/v1/asset/info` 返回的可选 `expiry_reminder` 作为本次临期主动提醒来源,在首页资产信息区展示可关闭横幅。剩余天数、到期时间、等级及等级名称 SHALL 使用 `remaining_days`、`expires_at`、`level`、`level_name`,SHALL NOT 用旧未读临期通知或客户端计算补发提醒。
#### Scenario: Asset info includes a reminder
- **WHEN** 当前资产响应含有效 expiry_reminder
- **THEN** H5 SHALL 展示该资产临期横幅及后端提供的提醒信息
#### Scenario: Asset info omits the optional field
- **WHEN** 响应无 expiry_reminder,且当前进入没有已接收的待展示提醒
- **THEN** H5 SHALL 不显示临期横幅,也不从 is_expiring 或通知列表补发
### Requirement: Reminder presentation SHALL preserve server calendar and levels
H5 SHALL 正确显示 0~15 天,按后端 `red`(0~3)、`purple`(4~7)、`pink`(8~15)设置视觉等级,并提供文字信息。到期时间 SHALL 按 Asia/Shanghai 展示,SHALL NOT 按客户端日期重新判定等级或剩余自然日。
#### Scenario: Zero remaining days
- **WHEN** 后端返回 remaining_days=0、level=red
- **THEN** H5 SHALL 正常展示 0 天及红色等级,不能把 0 当作缺失
#### Scenario: Client timezone differs from Shanghai
- **WHEN** 用户设备的本地时区不是 Asia/Shanghai
- **THEN** H5 SHALL 保留后端天数和等级,到期时间使用上海时区呈现
### Requirement: One-time reminder delivery SHALL survive competing asset-info consumers
H5 SHALL 在 asset/info 的共享成功返回入口接收一次性提醒,按当前客户上下文及资产隔离并暂存至对应资产可见。后端负责每日一次,前端 SHALL 防止同次接收被重复展示,SHALL NOT 因并发空字段响应覆盖已接收提醒、使用额外轮询或在关闭后复播。
#### Scenario: Child component receives the only reminder response
- **WHEN** TrafficCard 等调用方先获得 expiry_reminder,首页后续响应未返回该字段
- **THEN** H5 SHALL 通过共享接收状态在当前资产信息区展示该提醒,不将其丢弃
#### Scenario: Same asset is re-entered after dismissal
- **WHEN** 用户关闭提醒后再次进入同一资产,后端不再返回字段
- **THEN** H5 SHALL 不重播先前已展示提醒
#### Scenario: User switches customer or asset
- **WHEN** 用户退出、切换客户或进入另一资产
- **THEN** H5 SHALL 不显示旧上下文提醒;其他资产有新提醒时 SHALL 独立处理
### Requirement: Expiry notifications SHALL remain in the notification center without driving popups
H5 SHALL 从首页旧未读通知弹窗来源排除临期项,同时保留通知中心临期列表、未读数和既有阅读操作。临期横幅展示或关闭 SHALL NOT 自动标记通知已读,其他通知及候选弹窗链路 SHALL 保持不变。
#### Scenario: Unread expiry notification exists without a new reminder
- **WHEN** 通知列表返回未读临期项,但 asset/info 无本次提醒
- **THEN** H5 SHALL 不以该项触发旧首页临期弹窗
- **AND** 通知中心 SHALL 仍可展示和阅读该项
#### Scenario: User closes an expiry banner
- **WHEN** 用户关闭临期横幅
- **THEN** H5 SHALL 不猜测 notification_id、不发送自动单条已读或全部已读请求

View File

@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: Phone guidance SHALL use the current login asset facts
H5 SHALL 保存 `POST /api/c/v1/auth/wechat-login` 的 `asset_phone_bound`、`asset_phone_is_own`、`need_bind_phone`,分别表达资产是否绑号、号码归属和是否提示绑定。状态 SHALL 按当前账号及资产隔离;客户 `bound_phone` 和旧本地缓存 SHALL NOT 替代资产布尔事实。
#### Scenario: Customer has a phone but the asset is unbound
- **WHEN** 客户有手机号,登录返回 `asset_phone_bound=false` 和 `need_bind_phone=true`
- **THEN** H5 SHALL 引导用户给资产绑定号码
- **AND** SHALL NOT 因客户手机号非空或旧缓存 bound=true 跳过提示
#### Scenario: Asset belongs to another phone and guidance is not required
- **WHEN** 登录返回 `asset_phone_bound=true`、`asset_phone_is_own=false`、`need_bind_phone=false`
- **THEN** H5 SHALL 认为资产已有手机号,不要求重新绑定
- **AND** SHALL NOT 推测或显示关联号码
#### Scenario: Account or asset context changes
- **WHEN** 用户退出、切换账号或切换资产
- **THEN** H5 SHALL 清理或更换绑定事实,不能复用上一上下文的布尔值或手机号缓存
### Requirement: Automatic binding guidance SHALL follow need_bind_phone
H5 SHALL 仅按后端 `need_bind_phone` 触发自动绑定提示,SHALL NOT 根据手机号为空或 `asset_phone_is_own=false` 擅自增加自动跳转;缺失字段 SHALL 保持未知而非视为绑定成功。
#### Scenario: Force binding is disabled
- **WHEN** 强制绑定关闭,登录返回 `need_bind_phone=false`
- **THEN** H5 SHALL 正常进入首页,不自动跳转绑定页
#### Scenario: Guidance is required
- **WHEN** 登录返回 `need_bind_phone=true`
- **THEN** H5 SHALL 进入现有强制绑定流程,并按资产事实区分给客户绑号与给资产绑号

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Notification queries SHALL preserve backend current-asset expiry scope
H5 SHALL 保持 `GET /api/c/v1/notifications` 与 `GET /api/c/v1/notifications/unread-count` 的现有入参,使用后端返回的当前资产临期通知及未读计数。H5 SHALL NOT 扩大范围查询所有资产、为这些接口新增未声明参数,或将普通通知一并误过滤为仅当前资产。
#### Scenario: Customer views asset A
- **WHEN** 客户在资产 A 的认证上下文读取列表和未读数
- **THEN** H5 SHALL 展示后端结果,其中临期提醒按 A 过滤
- **AND** SHALL NOT 混入资产 B 的旧列表或计数
#### Scenario: Current asset changes
- **WHEN** 当前认证资产由 A 切换为 B
- **THEN** H5 SHALL 刷新列表和未读数,防止 A 的延迟响应覆盖 B
### Requirement: Mark-all-read SHALL use POST and preserve other assets' expiry notifications
H5 SHALL 在用户主动点击通知中心“全部已读”时调用 `POST /api/c/v1/notifications/read-all`,SHALL NOT 使用或自动回退旧 PUT。其他资产的临期通知 SHALL 由后端保留未读;客户端成功后 SHALL 按服务端刷新结果更新当前视图,失败 SHALL 保留未读状态。单条已读 SHALL 继续使用 `PUT /api/c/v1/notifications/{id}/read`。
#### Scenario: User marks all notifications read on asset A
- **WHEN** 客户在 A 上主动执行全部已读
- **THEN** H5 SHALL 发送 POST 并对齐当前视图
- **AND** 资产 B 的临期通知 SHALL 保持未读
#### Scenario: Mark-all-read fails
- **WHEN** POST 全部已读请求失败
- **THEN** H5 SHALL 不提前清空未读计数或把本地项全部标成已读,也不改用 PUT 重试
#### Scenario: User reads one notification
- **WHEN** 用户执行单条已读操作
- **THEN** H5 SHALL 继续发送对应通知的 PUT 请求,不受 read-all 方法切换影响
#### Scenario: Reminder or popup is displayed
- **WHEN** 临期横幅、运营候选或风险换货弹窗展示或关闭
- **THEN** H5 SHALL 不自动调用 read-all 来结束展示链路

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Binding SHALL distinguish customer and asset phone targets
H5 SHALL 按 F1 四种场景组织绑定表单,继续使用 `POST /api/c/v1/auth/bind-phone` 的 `{ phone, code }` 与既有验证码场景。客户号码预填 SHALL 可编辑,SHALL NOT 要求未绑定资产只能绑定客户主号,也 SHALL NOT 用资产布尔字段推导实际号码。
#### Scenario: Neither the customer nor the asset has a phone
- **WHEN** 客户和资产都无手机号,用户输入号码并成功验证
- **THEN** H5 SHALL 按后端同时绑定客户和资产的结果完成流程
#### Scenario: Only the customer has a phone
- **WHEN** 客户已有号码,资产未关联号码
- **THEN** H5 SHALL 提示给当前资产绑号,允许提交与客户号码不同的号码
- **AND** 成功后 SHALL NOT 把输入的资产号码当成客户的新号码
#### Scenario: Only the asset has a phone
- **WHEN** 客户无号码,资产已有号码,用户成功提交客户绑定
- **THEN** H5 SHALL 仅更新客户手机号状态,不覆盖或改变资产关联
#### Scenario: Both the customer and the asset have phones
- **WHEN** 客户和资产都已有号码
- **THEN** H5 SHALL 不要求绑定,无论资产号码是否属于当前客户
### Requirement: Asset phone conflicts SHALL direct the user to an administrator
H5 SHALL 对 `bind-phone` 的 `code=1192` 提示“该资产已绑定手机号,如需更换请联系管理员”,SHALL NOT 重试当前资产改绑请求、暴露号码或调用后台解除接口。其他错误 SHALL 保留后端消息,失败 SHALL NOT 更新绑定成功状态。
#### Scenario: Binding returns 1192
- **WHEN** 后端拒绝资产改绑并返回 1192
- **THEN** H5 SHALL 显示联系管理员提示并停止当前改绑操作,不提供同请求重试
#### Scenario: Phone is owned by another customer
- **WHEN** 绑定返回既有 1184 错误
- **THEN** H5 SHALL 保留相应后端错误提示,不误报为绑定成功或转换成 1192
### Requirement: Binding completion SHALL preserve existing navigation
H5 SHALL 保留强制绑定成功后回登录页、恢复 identifier 并等待用户手动重登的行为,以及普通绑定成功返回上一页的行为。状态更新 SHALL 对应本次客户/资产实际绑定对象,SHALL NOT 一律将输入号码写入资产缓存。
#### Scenario: Mandatory binding succeeds
- **WHEN** 用户从强制绑定入口成功提交
- **THEN** H5 SHALL 返回登录页,保留 identifier 并提示手动重登,不自动 OAuth 重登
#### Scenario: Ordinary binding succeeds
- **WHEN** 用户从首页普通入口成功提交
- **THEN** H5 SHALL 返回上一页,按后端结果更新相关状态,不强制返回登录页

View File

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Customer phone changes SHALL leave asset phone associations unchanged
H5 SHALL 按当前 F1 契约调用 `POST /api/c/v1/auth/change-phone`,只更换客户手机号。原/新号码及验证码入参、`change_phone_old` / `change_phone_new` 场景 SHALL 保持不变;H5 SHALL NOT 将客户新号写入资产关联缓存或宣称所有资产关联已迁移。
#### Scenario: Customer changes to a different phone
- **WHEN** 客户换号成功,后端确认新客户手机号
- **THEN** H5 SHALL 更新客户手机号及后续换号的原号来源
- **AND** SHALL 保持资产关联不变,不调用新号写入资产绑定缓存的逻辑
#### Scenario: Asset ownership fact becomes stale
- **WHEN** 客户更换为不同号码,原归属事实为 true,响应未返回最新资产归属
- **THEN** H5 SHALL 失效旧 `asset_phone_is_own` 事实,等真实登录响应确认
- **AND** SHALL NOT 将资产误标为未绑定或要求重新绑定
### Requirement: Customer phone change failures SHALL preserve existing state
H5 SHALL 在换号失败时展示后端错误并留在表单,保留原客户及资产状态;SHALL NOT 套用旧“全部资产事务迁移/回滚”的假设或固定文案。
#### Scenario: Change-phone returns a business error
- **WHEN** 换号请求失败
- **THEN** H5 SHALL 显示实际错误消息并留在页面
- **AND** SHALL NOT 更新客户号码、资产号码或缓存

View File

@@ -0,0 +1,46 @@
## 1. 状态拆分与绑定入口
- [x] 1.1 保存登录的 asset_phone_bound / asset_phone_is_own / need_bind_phone,按当前账号与资产隔离,在退出/切换时清理。
- [x] 1.2 区分客户手机号、资产关联和提示状态;bound_phone 只沿用客户手机号用途,不用它推断资产关联。
- [x] 1.3 移除首页对旧 phoneBindStatus 的优先信任,确保历史缓存不能覆盖服务端状态或跨客户复用。
- [x] 1.4 调整首页手机号展示和 FunctionCard 绑定入口:客户有号但资产无号仍可绑定;资产归属 false 不表示未绑定。
- [x] 1.5 保留 need_bind_phone 驱动的登录提示,强制开关关闭时不额外自动跳转。
## 2. 绑定与客户换号
- [x] 2.1 实现 F1 四种绑定场景的表单文案、可编辑预填、成功后的分类状态更新;不新增请求字段。
- [x] 2.2 对 1192 显示联系管理员提示并停止当前改绑重试,保留 1184 及其他后端错误提示;失败不更新绑定缓存。
- [x] 2.3 保留强制绑定成功后的手动重登、identifier 保留,以及普通绑定成功返回上一页的行为。
- [x] 2.4 换号只更新客户手机号,删除新号写入资产缓存的行为;不再声明全部关联迁移/事务回滚。
- [x] 2.5 换号后更新客户原号来源并失效可能过期的 asset_phone_is_own;不将原资产标记未绑定,不自动要求改绑。
## 3. 临期提醒
- [x] 3.1 保留 expiry_reminder 的可选语义及四个字段,并在 assetApi.getInfo 的共同返回入口接收一次性提醒。
- [x] 3.2 实现当前账号/资产隔离的临时共享提醒状态;同次进入的后续空字段响应不能覆盖已接收的提醒。
- [x] 3.3 在首页资产信息区显示可关闭横幅,按后端 red / purple / pink 展示,正确处理 0 天及上海时区的到期时间。
- [x] 3.4 检查 TrafficCard、绑定预填、RenewalPaymentPopup 及其他 getInfo 调用方;强制绑定页不为预填请求 asset/info,避免跨重登传递一次性提醒。
- [x] 3.5 从首页旧通知弹窗来源排除临期通知,通知中心保留;不影响其他通知及风险换货/运营弹窗。
- [x] 3.6 不为临期提醒新增轮询、每日补发、无字段补弹或自动已读请求。
## 4. 当前资产通知与全部已读
- [x] 4.1 将 markAllRead 切换为 POST /api/c/v1/notifications/read-all,不回退 PUT;单条已读继续 PUT。
- [x] 4.2 保持列表/未读数入参不变,切换资产后刷新相关结果,禁止旧资产结果覆盖新资产。
- [x] 4.3 全部已读成功后按服务端结果对齐当前列表/计数,失败保留未读状态;不在任何提醒/弹窗展示链路调用 read-all。
## 5. 验证与交付
- [x] 5.1 通过自动/模拟测试验证四种绑定模式及强制开关关闭;真实微信授权验收另见 IMPLEMENTATION.md。
- [x] 5.2 通过模拟测试和本地浏览器验证不同号码输入、1192 联系管理员且不重试、1184 提示、失败状态不变。
- [x] 5.3 通过自动/模拟测试验证换号只更新客户、资产关联保持、归属失效及旧缓存/账号/资产隔离。
- [x] 5.4 通过自动/模拟测试验证字段缺省、0/3/4/7/8/15 天及等级、上海日期边界、重复进入和 A/B 上下文;浏览器检查 0 天及关闭行为。
- [x] 5.5 通过共享接收和首页脚本测试验证并发/其他调用方先返回提醒不丢失,以及旧首页弹窗只排除临期通知。
- [ ] 5.6 用同一客户的 A/B 两资产验证列表、未读数与 A 上 POST 全部已读;B 的临期通知仍保持未读,普通通知范围不被前端扩大过滤。
- [ ] 5.7 验证单条已读仍 PUT、read-all 失败不提前清空、运营/风险换货弹窗和支付/实名/套餐展示不回归。
- [x] 5.8 执行 npm run build:h5、npm run build:h5:production,均通过;实施记录明确区分模拟验证与待真实联调场景。
- [x] 5.9 执行 openspec validate update-october-batch1-h5-integration --strict,校验通过;仅按实际完成结果更新本任务清单。
- [x] 5.10 新增并执行 npm run test:h5-integration,14 组自动/模拟测试全部通过。
- [x] 5.11 使用本地模拟预览检查移动端临期横幅、绑定表单预填/不同号输入及 1192 弹窗/禁用操作;不发送真实业务请求。
5.6 的客户端 A/B 隔离逻辑、5.7 的单条 PUT 和失败不提前清空均已模拟测试通过;真实后端范围及完整支付/实名/候选弹窗回归未完成,不勾选。

View File

@@ -2,7 +2,8 @@
"scripts": {
"dev:h5": "uni",
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode development",
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production"
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production",
"test:h5-integration": "node --experimental-vm-modules --test tests/october-h5.test.mjs"
},
"devDependencies": {
"@dcloudio/types": "3.4.19",

View File

@@ -5,12 +5,14 @@
<view class="navbar-back" @tap="goBack">
<image class="back-icon" src="../../static/left.png" mode="aspectFit" />
</view>
<view class="navbar-title">绑定手机号</view>
<view class="navbar-title">{{ bindingTitle }}</view>
<view class="navbar-placeholder"></view>
</view>
<view class="container" :style="{ paddingTop: fromLogin ? '0' : '0' }">
<view class="card flex-col-g20">
<view class="binding-description">{{ bindingDescription }}</view>
<view v-if="assetConflict" class="binding-conflict">该资产已绑定手机号,如需更换请联系管理员</view>
<view class="flex-row-g20">
<label for="">手机号:</label>
<up-input placeholder="请输入绑定的手机号" border="surround" v-model="bind.phone" />
@@ -20,13 +22,13 @@
<label for="">验证码:</label>
<up-input placeholder="验证码" v-model="bind.code">
<template #suffix>
<up-button @tap="getCode" :text="codeText" type="success" :disabled="cooldown > 0"></up-button>
<up-button @tap="getCode" :text="codeText" type="success" :disabled="cooldown > 0 || assetConflict || initializing || bindingMode === 'none'"></up-button>
</template>
</up-input>
</view>
<view class="btn">
<up-button type="primary" @tap="bindPhone">绑定手机号</up-button>
<up-button type="primary" @tap="bindPhone" :loading="submitting" :disabled="initializing || submitting || assetConflict || bindingMode === 'none'">{{ bindingTitle }}</up-button>
</view>
<!-- 如果从登录页面跳转过来,显示退出按钮 -->
@@ -39,14 +41,31 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { authApi, assetApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { setPhoneBindStatus } from '@/utils/phone-bind.js';
import { getPhoneBindingMode } from '@/utils/phone-bind.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
const userStore = useUserStore();
const initializing = ref(true);
const submitting = ref(false);
const assetConflict = ref(false);
const bindingMode = computed(() => getPhoneBindingMode(userStore.state.customerPhone, userStore.state.assetPhoneBound));
const bindingTitle = computed(() => {
if (bindingMode.value === 'customer') return '绑定客户手机号';
if (bindingMode.value === 'asset') return '给资产绑定手机号';
return '绑定手机号';
});
const bindingDescription = computed(() => ({
customer: '请绑定您自己的手机号,当前资产的手机号关联不会改变。',
asset: '请给当前资产绑定手机号,可使用与您的客户手机号不同的号码。',
'customer-asset': '验证一次手机号,将同时绑定您的客户账号和当前资产。',
none: '您的客户账号和当前资产均已绑定手机号,无需重复绑定。',
unknown: '请验证手机号。客户与资产的绑定将由系统按当前状态处理,已有的资产手机号不会被替换。'
}[bindingMode.value]));
let bind = reactive({
phone: '',
@@ -65,21 +84,33 @@
const options = currentPage.options;
fromLogin.value = options.fromLogin === 'true';
// 契约变化:账号已有主手机号时,主号 + 验证码有效 → 幂等建联。
// 预填账号已有主手机号,避免存量用户「提示要验证却提交任何号码都被拒」的死锁。
if (userStore.state.customerPhone) bind.phone = userStore.state.customerPhone;
// Mandatory binding ends with a new login. Do not consume the day's
// one-time expiry reminder here merely to prefill a phone input.
if (fromLogin.value) {
initializing.value = false;
return;
}
const token = uni.getStorageSync('token');
const identifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
if (!token || !identifier) return;
if (!token || !identifier) {
initializing.value = false;
return;
}
const context = getSessionContext();
try {
const data = await assetApi.getInfo(identifier);
const mainPhone = (data && data.bound_phone) || '';
if (!isCurrentSession(context)) return;
const mainPhone = data?.bound_phone || '';
if (mainPhone) {
bind.phone = mainPhone;
}
} catch (e) {
// 预填失败不阻塞绑定流程,用户仍可手动输入手机号
console.error('预填主手机号失败', e);
console.error('预填客户手机号失败');
} finally {
initializing.value = false;
}
});
@@ -101,6 +132,7 @@
};
const getCode = async () => {
if (cooldown.value > 0 || initializing.value || assetConflict.value || bindingMode.value === 'none') return;
if (!bind.phone) {
uni.showToast({ title: '请输入手机号', icon: 'none' });
return;
@@ -111,8 +143,10 @@
return;
}
const context = getSessionContext();
try {
const data = await authApi.sendCode(bind.phone, 'bind_phone');
if (!isCurrentSession(context)) return;
cooldown.value = data.cooldown_seconds || 60;
codeText.value = `${cooldown.value}s`;
@@ -133,15 +167,24 @@
};
const bindPhone = async () => {
if (initializing.value || submitting.value || assetConflict.value || bindingMode.value === 'none') return;
if (!bind.phone || !bind.code) {
uni.showToast({ title: '手机号和验证码都不能为空', icon: 'none' });
return;
}
if (!/^1[3-9]\d{9}$/.test(bind.phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
const context = getSessionContext();
const mode = bindingMode.value;
const submittedPhone = bind.phone;
submitting.value = true;
try {
await authApi.bindPhone(bind.phone, bind.code);
const identifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
setPhoneBindStatus(identifier, bind.phone);
await authApi.bindPhone(submittedPhone, bind.code);
if (!isCurrentSession(context)) return;
userStore.completePhoneBinding(submittedPhone, mode);
if (fromLogin.value) {
prepareManualRelogin();
@@ -152,10 +195,18 @@
uni.showToast({ title: '绑定成功', icon: 'success' });
setTimeout(() => {
// 从其他页面过来的,返回上一页
uni.navigateBack();
if (isCurrentSession(context)) uni.navigateBack();
}, 1500);
} catch (e) {
console.error('绑定手机号失败', e);
if (!isCurrentSession(context)) return;
if (Number(e?.code) === 1192) {
assetConflict.value = true;
uni.showModal({ title: '无法更换资产手机号', content: '该资产已绑定手机号,如需更换请联系管理员', showCancel: false });
} else {
uni.showToast({ title: e?.msg || e?.message || '绑定失败,请稍后重试', icon: 'none' });
}
} finally {
submitting.value = false;
}
};
@@ -165,12 +216,14 @@
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
userStore.clearUser();
uni.clearStorageSync();
uni.reLaunch({ url: '/pages/login/login' });
}
}
});
};
onUnmounted(() => clearInterval(timer));
const goBack = () => {
uni.navigateTo({
@@ -237,4 +290,6 @@
.container {
padding: var(--space-md);
}
.binding-description { color: var(--text-secondary); font-size: 26rpx; line-height: 1.6; }
.binding-conflict { color: var(--danger); font-size: 26rpx; line-height: 1.6; }
</style>

View File

@@ -1,6 +1,7 @@
<template>
<view class="container">
<view class="card">
<view class="caption mb-md">仅更换您的客户手机号,资产已绑定的手机号不会改变。</view>
<view class="form-item">
<view class="label">原手机号</view>
<up-input placeholder="请输入原手机号" border="surround" v-model="form.oldPhone" disabled/>
@@ -30,19 +31,20 @@
</view>
<view class="btn-wrapper">
<up-button type="primary" @click="submit">确认更换</up-button>
<up-button type="primary" :loading="submitting" :disabled="submitting" @click="submit">确认更换</up-button>
</view>
</view>
</view>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue';
import { authApi } from '@/api/index.js';
import { reactive, ref, onMounted, onUnmounted } from 'vue';
import { authApi, assetApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { setPhoneBindStatus } from '@/utils/phone-bind.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
const userStore = useUserStore();
const submitting = ref(false);
const form = reactive({
oldPhone: '',
@@ -51,14 +53,18 @@
newCode: ''
});
onMounted(() => {
// 获取 URL 参数中的原手机号
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options;
if (options.oldPhone) {
form.oldPhone = options.oldPhone;
onMounted(async () => {
form.oldPhone = userStore.state.customerPhone || '';
const context = getSessionContext();
const revision = userStore.state.phoneRevision;
try {
const data = await assetApi.getInfo(context.identifier);
if (isCurrentSession(context)) {
form.oldPhone = revision === userStore.state.phoneRevision
? data.bound_phone || '' : userStore.state.customerPhone || '';
}
} catch (e) {
console.error('加载客户手机号失败');
}
});
@@ -126,6 +132,7 @@
};
const submit = async () => {
if (submitting.value) return;
if (!form.oldPhone || !form.oldCode) {
uni.showToast({ title: '请填写原手机号信息', icon: 'none' });
return;
@@ -134,19 +141,34 @@
uni.showToast({ title: '请填写新手机号信息', icon: 'none' });
return;
}
if (!/^1[3-9]\d{9}$/.test(form.newPhone)) {
uni.showToast({ title: '请输入正确的新手机号', icon: 'none' });
return;
}
const context = getSessionContext();
submitting.value = true;
try {
await authApi.changePhone(form.oldPhone, form.oldCode, form.newPhone, form.newCode);
setPhoneBindStatus(userStore.state.identifier, form.newPhone);
form.oldPhone = form.newPhone;
const data = await authApi.changePhone(form.oldPhone, form.oldCode, form.newPhone, form.newCode);
if (!isCurrentSession(context)) return;
if (typeof data?.phone === 'string') {
userStore.completeCustomerPhoneChange(data.phone);
form.oldPhone = data.phone;
}
uni.showToast({ title: '更换成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
if (isCurrentSession(context)) uni.navigateBack();
}, 1500);
} catch (e) {
console.error('更换手机号失败', e);
} finally {
submitting.value = false;
}
};
onUnmounted(() => {
clearInterval(oldTimer);
clearInterval(newTimer);
});
</script>
<style lang="scss" scoped>

View File

@@ -3,6 +3,8 @@
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus"
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status"
:isExpiring="deviceInfo.isExpiring" :expiryDays="deviceInfo.expiryDays" @renew="startHomepageRenewal" />
<ExpiryReminderBanner v-if="visibleExpiryReminder" :reminder="visibleExpiryReminder"
@close="closeExpiryReminder" />
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
@@ -10,7 +12,7 @@
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
@openDatePicker="openDateRangePicker" /> -->
<TrafficCard :identifier="currentCardNo" />
<TrafficCard :identifier="userStore.state.identifier" />
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
@@ -18,7 +20,8 @@
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi"
@copy-config="copyWifiConfig" />
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
<FunctionCard :realNameStatus="realNameStatus" :assetPhoneBound="userStore.state.assetPhoneBound"
:customerPhone="userStore.state.customerPhone"
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" :unreadCount="notificationUnreadCount"
@enter="enterDetail" @sync="onSync">
<!-- 修改配置弹窗 -->
@@ -124,9 +127,10 @@
ref,
reactive,
onMounted,
computed
computed,
watch
} from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { onShow, onHide } from '@dcloudio/uni-app';
import UserInfoCard from '@/components/UserInfoCard.vue';
import DeviceStatusCard from '@/components/DeviceStatusCard.vue';
import VoiceCard from '@/components/VoiceCard.vue';
@@ -136,6 +140,7 @@
import FunctionCard from '@/components/FunctionCard.vue';
import FloatingButton from '@/components/FloatingButton.vue';
import NotificationPopup from '@/components/NotificationPopup.vue';
import ExpiryReminderBanner from '@/components/ExpiryReminderBanner.vue';
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
import PopupCandidate from '@/components/PopupCandidate.vue';
import {
@@ -145,12 +150,33 @@
notificationApi
} from '@/api/index.js';
import {
useUserStore
useUserStore,
useAssetStore
} from '@/store/index.js';
import { consumePendingPaymentRefresh, PAYMENT_REFRESH_TARGETS } from '@/utils/payment.js';
import { getPhoneBindStatus, setPhoneBindStatus } from '@/utils/phone-bind.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
import { getShanghaiDay } from '@/utils/expiry-reminder.js';
const userStore = useUserStore();
const assetStore = useAssetStore();
const homeActive = ref(false);
const shownExpiryReminder = ref(null);
const visibleExpiryReminder = computed(() => {
const entry = shownExpiryReminder.value;
return homeActive.value && entry && isCurrentSession(entry) && entry.day === getShanghaiDay()
? entry.reminder : null;
});
const showPendingExpiryReminder = () => {
const entry = assetStore.state.expiryReminder;
if (!homeActive.value || !entry || entry.shown || !isCurrentSession(entry) || entry.day !== getShanghaiDay()) return;
shownExpiryReminder.value = { ...entry };
assetStore.markExpiryReminderShown();
};
const closeExpiryReminder = () => {
shownExpiryReminder.value = null;
assetStore.dismissExpiryReminder();
};
watch(() => assetStore.state.expiryReminder, showPendingExpiryReminder);
let showModifyWifi = ref(false);
let restartShow = ref(false);
@@ -200,8 +226,8 @@
});
let isRealName = ref(false);
let alreadyBindPhone = ref(false);
let boundPhone = ref('');
const boundPhone = computed(() => userStore.state.customerPhone || '');
watch(boundPhone, (phone) => { deviceInfo.bound_phone = phone; });
let realNameStatus = ref('');
let effectiveRealnamePolicy = ref('none');
let realnameEntryChecking = ref(false);
@@ -259,11 +285,12 @@
const loadAssetInfo = async () => {
const identifier = userStore.state.identifier;
if (!identifier) return;
const localBindStatus = getPhoneBindStatus(identifier);
const context = getSessionContext();
loading.value = true;
try {
const data = await assetApi.getInfo(identifier);
if (!isCurrentSession(context)) return null;
// 基本信息
userInfo.isDevice = data.asset_type === 'device';
@@ -272,9 +299,7 @@
deviceInfo.status = data.status;
deviceInfo.imei = data.imei || '-';
deviceInfo.asset_type = data.asset_type || 'device';
const apiBoundPhone = data.bound_phone || '';
const effectiveBoundPhone = localBindStatus?.phone || apiBoundPhone || '';
deviceInfo.bound_phone = effectiveBoundPhone;
deviceInfo.bound_phone = userStore.state.customerPhone || '';
deviceInfo.packageName = data.current_package || '-';
deviceInfo.currentPackageId = Number(data.current_package_id || 0);
deviceInfo.renewalPrice = data.renewal_price ?? null;
@@ -289,11 +314,6 @@
realNameStatus.value = isRealName.value ? '已实名' : '未实名';
effectiveRealnamePolicy.value = data.effective_realname_policy || 'none';
userStore.setRealNameStatus(data.real_name_status);
boundPhone.value = effectiveBoundPhone;
alreadyBindPhone.value = !!(apiBoundPhone || localBindStatus?.bound);
if (apiBoundPhone && (!localBindStatus || apiBoundPhone === localBindStatus.phone)) {
setPhoneBindStatus(identifier, apiBoundPhone);
}
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
@@ -539,6 +559,7 @@
recoverShow.value = false;
};
const YesLogout = () => {
userStore.clearUser();
uni.clearStorageSync();
uni.reLaunch({
url: '/pages/login/login'
@@ -820,7 +841,7 @@
return;
}
uni.navigateTo({
url: `/pages/change-phone/change-phone?oldPhone=${boundPhone.value}`
url: '/pages/change-phone/change-phone'
});
break;
case 'device-exchange':
@@ -849,8 +870,10 @@
};
const loadNotificationUnreadCount = async () => {
const context = getSessionContext();
try {
const data = await notificationApi.getUnreadCount();
if (!isCurrentSession(context)) return;
notificationUnreadCount.value = Number(data?.count || 0);
} catch (error) {
console.error('加载通知未读数失败', error);
@@ -860,8 +883,10 @@
const markNotificationRead = async (item) => {
if (!item || item.is_read || notificationReadPending.has(item.id)) return;
notificationReadPending.add(item.id);
const context = getSessionContext();
try {
await notificationApi.markRead(item.id);
if (!isCurrentSession(context)) return;
item.is_read = true;
item.read_at = new Date().toISOString();
notificationUnreadCount.value = Math.max(notificationUnreadCount.value - 1, 0);
@@ -873,10 +898,12 @@
};
const loadUnreadNotifications = async () => {
const context = getSessionContext();
try {
const data = await notificationApi.getList(1, 50, false);
if (!homeActive.value || !isCurrentSession(context)) return;
const items = (data?.items || [])
.filter(item => !item.is_read);
.filter(item => !item.is_read && item.category !== 'expiry');
if (!items.length) return;
notificationItems.value = items;
@@ -923,6 +950,13 @@
});
onShow(() => {
homeActive.value = true;
shownExpiryReminder.value = null;
showPendingExpiryReminder();
if (!getSessionContext().token) {
handleIndexEntry();
return;
}
if (consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME)) {
loadAssetInfo();
}
@@ -930,6 +964,17 @@
loadNotificationUnreadCount();
runPopupCandidate();
});
onHide(() => {
homeActive.value = false;
shownExpiryReminder.value = null;
notificationPopupShow.value = false;
});
watch(() => [userStore.state.token, userStore.state.identifier], () => {
shownExpiryReminder.value = null;
notificationItems.value = [];
notificationUnreadCount.value = 0;
notificationPopupShow.value = false;
});
</script>
<style scoped lang="scss">

View File

@@ -339,6 +339,7 @@
try {
const loginData = await authApi.wechatLogin(assetToken, code);
userStore.setToken(loginData.token);
userStore.setLoginPhoneState(loginData);
uni.setStorageSync('identifier', userStore.state.identifier);
// #ifdef H5
@@ -350,7 +351,7 @@
}
// #endif
if (loginData.need_bind_phone) {
if (userStore.state.needBindPhone === true) {
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
} else {
uni.redirectTo({ url: '/pages/index/index' });

View File

@@ -2,7 +2,7 @@
<view class="container">
<view class="toolbar flex-row-sb">
<view class="summary">共 {{ total }} 条通知</view>
<button class="btn-apple btn-secondary mark-all" :disabled="unreadCount === 0" @tap="markAllRead">
<button class="btn-apple btn-secondary mark-all" :disabled="unreadCount === 0 || markingAll" @tap="markAllRead">
全部已读
</button>
</view>
@@ -40,9 +40,10 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { notificationApi } from '@/api/index.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
const pageSize = 20;
const notifications = reactive([]);
@@ -52,6 +53,10 @@
const loading = ref(false);
const notificationsLoaded = ref(false);
const noMore = ref(false);
const markingAll = ref(false);
let listRequestId = 0;
let countRequestId = 0;
const readPending = new Set();
const formatDate = (value) => {
if (!value) return '-';
@@ -73,8 +78,11 @@
}[severity] || '提示');
const loadUnreadCount = async () => {
const context = getSessionContext();
const requestId = ++countRequestId;
try {
const data = await notificationApi.getUnreadCount();
if (!isCurrentSession(context) || requestId !== countRequestId) return;
unreadCount.value = Number(data?.count || 0);
} catch (error) {
console.error('加载通知未读数失败', error);
@@ -82,10 +90,14 @@
};
const loadNotifications = async (append = false) => {
if (loading.value || (append && noMore.value)) return;
if (append && (loading.value || noMore.value)) return;
const context = getSessionContext();
const requestId = ++listRequestId;
const requestedPage = append ? page.value : 1;
loading.value = true;
try {
const data = await notificationApi.getList(page.value, pageSize);
const data = await notificationApi.getList(requestedPage, pageSize);
if (!isCurrentSession(context) || requestId !== listRequestId) return;
const items = (data?.items || []);
if (append) {
notifications.push(...items);
@@ -94,39 +106,48 @@
}
total.value = Number(data?.total || 0);
noMore.value = notifications.length >= total.value || items.length < pageSize;
if (!noMore.value) page.value += 1;
page.value = requestedPage + 1;
} catch (error) {
console.error('加载通知列表失败', error);
} finally {
loading.value = false;
notificationsLoaded.value = true;
if (isCurrentSession(context) && requestId === listRequestId) {
loading.value = false;
notificationsLoaded.value = true;
}
}
};
const markItemRead = async (item) => {
if (item.is_read) return;
if (item.is_read || readPending.has(item.id) || markingAll.value) return;
const context = getSessionContext();
readPending.add(item.id);
try {
await notificationApi.markRead(item.id);
if (!isCurrentSession(context)) return;
item.is_read = true;
item.read_at = new Date().toISOString();
unreadCount.value = Math.max(unreadCount.value - 1, 0);
await loadUnreadCount();
} catch (error) {
console.error('标记通知已读失败', error);
} finally {
readPending.delete(item.id);
}
};
const markAllRead = async () => {
if (!unreadCount.value) return;
if (!unreadCount.value || markingAll.value) return;
const context = getSessionContext();
markingAll.value = true;
try {
await notificationApi.markAllRead();
notifications.forEach(item => {
item.is_read = true;
item.read_at = item.read_at || new Date().toISOString();
});
unreadCount.value = 0;
if (!isCurrentSession(context)) return;
await Promise.all([loadNotifications(), loadUnreadCount()]);
if (!isCurrentSession(context)) return;
uni.showToast({ title: '已全部标记为已读', icon: 'success' });
} catch (error) {
console.error('全部标记通知已读失败', error);
} finally {
markingAll.value = false;
}
};
@@ -134,13 +155,14 @@
if (!noMore.value) loadNotifications(true);
};
onMounted(() => {
loadNotifications();
loadUnreadCount();
});
onShow(() => {
loadUnreadCount();
onShow(async () => {
notifications.splice(0);
total.value = 0;
unreadCount.value = 0;
page.value = 1;
noMore.value = false;
notificationsLoaded.value = false;
await Promise.all([loadNotifications(), loadUnreadCount()]);
});
</script>

View File

@@ -1,12 +1,30 @@
import { reactive, ref } from 'vue';
import { reactive } from 'vue';
import { isCurrentSession } from '@/utils/session-context.js';
import { getShanghaiDay, normalizeExpiryReminder } from '@/utils/expiry-reminder.js';
const receivedReminders = new Set();
const state = reactive({
assetInfo: null,
deviceRealtime: null,
currentPackage: null
currentPackage: null,
expiryReminder: null
});
export const useAssetStore = () => {
const receiveExpiryReminder = (context, value) => {
const reminder = normalizeExpiryReminder(value);
if (!reminder || !isCurrentSession(context)) return;
const day = getShanghaiDay();
const key = JSON.stringify([context.token, context.identifier, day]);
if (receivedReminders.has(key)) return;
receivedReminders.add(key);
state.expiryReminder = { ...context, day, reminder, shown: false };
};
const markExpiryReminderShown = () => {
if (state.expiryReminder) state.expiryReminder.shown = true;
};
const dismissExpiryReminder = () => { state.expiryReminder = null; };
const setAssetInfo = (info) => {
state.assetInfo = info;
};
@@ -23,6 +41,8 @@ export const useAssetStore = () => {
state.assetInfo = null;
state.deviceRealtime = null;
state.currentPackage = null;
state.expiryReminder = null;
receivedReminders.clear();
};
return {
@@ -30,6 +50,9 @@ export const useAssetStore = () => {
setAssetInfo,
setDeviceRealtime,
setCurrentPackage,
receiveExpiryReminder,
markExpiryReminderShown,
dismissExpiryReminder,
clearAsset
};
};
};

View File

@@ -1,9 +1,32 @@
import { reactive, ref } from 'vue';
import { reactive } from 'vue';
import { optionalBoolean, getBindingSuccessState } from '@/utils/phone-bind.js';
import { useAssetStore } from './asset.js';
const PHONE_STATE_KEY = 'customerAssetPhoneState';
const emptyPhoneState = () => ({
customerPhone: null,
assetPhoneBound: null,
assetPhoneIsOwn: null,
needBindPhone: null
});
const savedPhoneState = uni.getStorageSync(PHONE_STATE_KEY);
const token = uni.getStorageSync('token') || '';
const identifier = uni.getStorageSync('identifier') || '';
const phoneState = savedPhoneState?.token === token && savedPhoneState?.identifier === identifier && token
? {
customerPhone: typeof savedPhoneState.customerPhone === 'string' ? savedPhoneState.customerPhone : null,
assetPhoneBound: optionalBoolean(savedPhoneState.assetPhoneBound),
assetPhoneIsOwn: optionalBoolean(savedPhoneState.assetPhoneIsOwn),
needBindPhone: optionalBoolean(savedPhoneState.needBindPhone)
}
: emptyPhoneState();
const state = reactive({
token: uni.getStorageSync('token') || '',
token,
assetToken: '',
identifier: uni.getStorageSync('identifier') || '',
identifier,
...phoneState,
phoneRevision: 0,
isDevice: false,
realNameStatus: 0,
userInfo: uni.getStorageSync('userInfo') || {
@@ -13,7 +36,25 @@ const state = reactive({
});
export const useUserStore = () => {
const persistPhoneState = () => {
uni.setStorageSync(PHONE_STATE_KEY, {
token: state.token, identifier: state.identifier,
customerPhone: state.customerPhone,
assetPhoneBound: state.assetPhoneBound,
assetPhoneIsOwn: state.assetPhoneIsOwn,
needBindPhone: state.needBindPhone
});
};
const resetContext = () => {
Object.assign(state, emptyPhoneState());
state.phoneRevision++;
uni.removeStorageSync(PHONE_STATE_KEY);
// The legacy per-asset cache mixed customer phones with asset phones.
uni.removeStorageSync('phoneBindStatus');
useAssetStore().clearAsset();
};
const setToken = (token) => {
if (state.token !== token) resetContext();
state.token = token;
uni.setStorageSync('token', token);
};
@@ -23,6 +64,7 @@ export const useUserStore = () => {
};
const setIdentifier = (identifier) => {
if (state.identifier !== identifier) resetContext();
state.identifier = identifier;
uni.setStorageSync('identifier', identifier);
};
@@ -40,7 +82,34 @@ export const useUserStore = () => {
uni.setStorageSync('userInfo', userInfo);
};
const setLoginPhoneState = (data) => {
state.assetPhoneBound = optionalBoolean(data.asset_phone_bound);
state.assetPhoneIsOwn = optionalBoolean(data.asset_phone_is_own);
state.needBindPhone = optionalBoolean(data.need_bind_phone);
// A required prompt with an already-bound asset means only the customer
// needs a phone. Other login combinations do not expose the customer phone.
if (state.assetPhoneBound === true && state.needBindPhone === true) state.customerPhone = '';
persistPhoneState();
};
const setCustomerPhone = (phone) => {
if (typeof phone !== 'string') return;
state.customerPhone = phone;
persistPhoneState();
};
const completePhoneBinding = (phone, mode) => {
Object.assign(state, getBindingSuccessState(state, phone, mode));
state.phoneRevision++;
persistPhoneState();
};
const completeCustomerPhoneChange = (phone) => {
state.customerPhone = phone;
state.assetPhoneIsOwn = null;
state.phoneRevision++;
persistPhoneState();
};
const clearUser = () => {
resetContext();
state.token = '';
state.assetToken = '';
state.identifier = '';
@@ -60,6 +129,10 @@ export const useUserStore = () => {
setIsDevice,
setRealNameStatus,
setUserInfo,
setLoginPhoneState,
setCustomerPhone,
completePhoneBinding,
completeCustomerPhoneChange,
clearUser
};
};

View File

@@ -0,0 +1,19 @@
// Load with agent-browser --init-script when checking the local preview.
if (location.hostname === '127.0.0.1' && location.port === '48138') {
localStorage.setItem('token', 'mock-session');
localStorage.setItem('identifier', 'A');
localStorage.setItem('customerAssetPhoneState', JSON.stringify({
type: 'object',
data: {
token: 'mock-session', identifier: 'A', customerPhone: '13800000001',
assetPhoneBound: false, assetPhoneIsOwn: false, needBindPhone: false
}
}));
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
if (typeof url === 'string' && url.includes('/api/c/v1/')) {
url = location.origin + '/__mock/' + url.split('/api/c/v1/')[1];
}
return originalOpen.call(this, method, url, ...rest);
};
}

29
tests/mock-h5-preview.mjs Normal file
View File

@@ -0,0 +1,29 @@
// Local-only visual QA: no request reaches the real backend.
import { preview } from 'vite';
const server = await preview({
configFile: false,
build: { outDir: 'dist/build/h5' },
preview: { host: '127.0.0.1', port: 48138, strictPort: true },
plugins: [{
name: 'mock-h5-api',
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => {
if (!req.url.startsWith('/__mock/')) return next();
let data = {};
if (req.url.startsWith('/__mock/asset/info')) data = {
identifier: 'A', asset_type: 'card', bound_phone: '13800000001',
current_package: '测试套餐', current_package_id: 1, current_package_usage_id: 1,
real_total_mb: 1024, real_used_mb: 256,
estimated_final_expires_at: '2026-10-13T16:00:00Z', days_until_final_expiry: 0, is_expiring: true,
expiry_reminder: { remaining_days: 0, expires_at: '2026-10-13T16:00:00Z', level: 'red', level_name: '即将到期' }
};
if (req.url.startsWith('/__mock/notifications')) data = { count: 0, items: [], total: 0 };
const conflict = req.url.startsWith('/__mock/auth/bind-phone');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ code: conflict ? 1192 : 0, msg: conflict ? '该资产已绑定手机号,如需更换请联系管理员' : 'success', data }));
});
}
}]
});
server.printUrls();

397
tests/october-h5.test.mjs Normal file
View File

@@ -0,0 +1,397 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const vue = require('vue');
const plain = (value) => JSON.parse(JSON.stringify(value));
const deferred = () => {
let resolvePromise;
const promise = new Promise(resolve => { resolvePromise = resolve; });
return { promise, resolve: resolvePromise };
};
// Execute actual store/API modules and page setup scripts with real Vue
// reactivity. Only native uni APIs and lifecycle scheduling are substituted.
function runtime(initial = {}) {
const storage = new Map(Object.entries({ token: 'session-A', identifier: 'A', ...initial }));
const calls = [];
const requests = [];
const mounted = [];
const shown = [];
const hidden = [];
const unmounted = [];
const timers = [];
const sessionStorage = new Map();
const api = {};
let options = {};
const uni = {
getStorageSync: key => storage.get(key) ?? '',
setStorageSync: (key, value) => storage.set(key, plain(value)),
removeStorageSync: key => storage.delete(key),
clearStorageSync: () => storage.clear(),
request: request => { requests.push(request); },
showToast: value => calls.push({ kind: 'toast', ...value }),
showModal: value => calls.push({ kind: 'modal', ...value }),
reLaunch: value => calls.push({ kind: 'reLaunch', ...value }),
redirectTo: value => calls.push({ kind: 'redirectTo', ...value }),
navigateTo: value => calls.push({ kind: 'navigateTo', ...value }),
navigateBack: () => calls.push({ kind: 'navigateBack' })
};
const context = vm.createContext({
uni, Intl, Date, URLSearchParams,
console: { log() {}, error() {}, warn() {} },
setTimeout: callback => { timers.push(callback); return timers.length; },
setInterval: () => 1, clearInterval() {},
getCurrentPages: () => [{ options }],
sessionStorage: { getItem: key => sessionStorage.get(key) || '', removeItem: key => sessionStorage.delete(key), setItem: (key, value) => sessionStorage.set(key, value) },
window: { location: { search: '', pathname: '/', hash: '' }, history: { replaceState() {} } },
defineProps: () => ({}), defineEmits: () => () => {}
});
const cache = new Map();
const synthetic = (key, values) => {
if (!cache.has(key)) {
cache.set(key, new vm.SyntheticModule(Object.keys(values), function () {
for (const [name, value] of Object.entries(values)) this.setExport(name, value);
}, { context, identifier: key }));
}
return cache.get(key);
};
function moduleFor(path, expose = []) {
if (cache.has(path)) return cache.get(path);
let source = readFileSync(path, 'utf8');
if (path.endsWith('.vue')) {
source = source.match(/<script setup>([\s\S]*?)<\/script>/)[1];
if (expose.length) source += `\nexport { ${expose.join(', ')} };`;
}
const module = new vm.SourceTextModule(source, { context, identifier: path });
cache.set(path, module);
return module;
}
async function linker(specifier, parent) {
if (specifier === 'vue') return synthetic('vue', {
...vue,
onMounted: cb => mounted.push(cb), onUnmounted: cb => unmounted.push(cb)
});
if (specifier === '@dcloudio/uni-app') return synthetic('uni-app', {
onShow: cb => shown.push(cb), onHide: cb => hidden.push(cb), onUnload: cb => unmounted.push(cb)
});
if (specifier === '@/api/index.js') return synthetic('api-index', {
...api, deviceApi: {}, hasActiveOrPendingPackage: async () => true
});
if (specifier === '@/utils/payment.js') return synthetic('payment', {
consumePendingPaymentRefresh: () => false, PAYMENT_REFRESH_TARGETS: { HOME: 'home' }
});
if (specifier === '@/utils/wxsdk.js') return synthetic('wxsdk', {
initWxConfig: async () => {}, wxScan: async () => {}, isInWechat: () => false
});
if (specifier === './env.js') return synthetic('env', { BASE_URL: 'https://example.invalid' });
if (specifier.endsWith('.vue')) return synthetic(specifier, { default: {} });
const path = specifier.startsWith('@/') ? resolve(root, specifier.slice(2)) : resolve(dirname(parent.identifier), specifier);
return moduleFor(path);
}
async function load(path, expose) {
const module = moduleFor(resolve(root, path), expose);
if (module.status === 'unlinked') await module.link(linker);
if (module.status === 'linked') await module.evaluate();
return module.namespace;
}
const reply = (request, data = {}, code = 0, statusCode = 200, msg = 'success') => {
request.success({ statusCode, data: { code, msg, data } });
};
return { storage, sessionStorage, context, calls, requests, mounted, shown, hidden, unmounted, timers, api, load, reply,
setOptions: value => { options = value; }, tick: () => vue.nextTick() };
}
test('phone binding modes and success updates distinguish all four cases', async () => {
const r = runtime();
const { getPhoneBindingMode: mode, getBindingSuccessState: success, optionalBoolean } = await r.load('utils/phone-bind.js');
assert.equal(mode('', false), 'customer-asset');
assert.equal(mode('13800000001', false), 'asset');
assert.equal(mode('', true), 'customer');
assert.equal(mode('13800000001', true), 'none');
assert.equal(mode(null, false), 'unknown');
assert.equal(optionalBoolean(undefined), null);
assert.equal(optionalBoolean('false'), null);
assert.equal(success({ customerPhone: '13800000001' }, '13900000002', 'asset').customerPhone, undefined);
assert.equal(success({ customerPhone: '13800000001' }, '13900000002', 'asset').assetPhoneIsOwn, false);
assert.equal(success({}, '13900000002', 'customer').assetPhoneBound, undefined);
assert.equal(success({}, '13900000002', 'customer-asset').assetPhoneBound, true);
});
test('login facts are persisted per token/asset; customer phone changes never migrate assets', async () => {
const r = runtime({ phoneBindStatus: { A: { bound: true, phone: '13900000002' } } });
const user = (await r.load('store/user.js')).useUserStore();
assert.equal(user.state.assetPhoneBound, null);
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: true });
user.setCustomerPhone('13800000001');
user.completePhoneBinding('13900000002', 'asset');
assert.equal(user.state.customerPhone, '13800000001');
assert.equal(user.state.assetPhoneBound, true);
assert.equal(user.state.assetPhoneIsOwn, false);
user.completeCustomerPhoneChange('13700000003');
assert.equal(user.state.customerPhone, '13700000003');
assert.equal(user.state.assetPhoneBound, true);
assert.equal(user.state.assetPhoneIsOwn, null);
const reloaded = runtime(Object.fromEntries(r.storage));
assert.equal((await reloaded.load('store/user.js')).useUserStore().state.assetPhoneBound, true);
user.setIdentifier('B');
assert.equal(user.state.assetPhoneBound, null);
assert.equal(user.state.customerPhone, null);
assert.equal(r.storage.has('phoneBindStatus'), false);
user.setToken('session-B');
assert.equal(user.state.needBindPhone, null);
user.clearUser();
assert.equal(r.storage.has('customerAssetPhoneState'), false);
});
test('login routing honors server guidance even with a phone bound to someone else', async () => {
for (const required of [false, true]) {
const r = runtime({ token: '' });
r.sessionStorage.set('assetToken', 'mock-asset-token');
r.context.window.location.search = '?code=mock-code';
r.api.wechatApi = {};
r.api.authApi = { wechatLogin: async () => ({ token: 'new-session', asset_phone_bound: true, asset_phone_is_own: false, need_bind_phone: required }) };
const page = await r.load('pages/login/login.vue', ['handleWechatCallback']);
await page.handleWechatCallback();
assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, required ? '/pages/bind/bind?fromLogin=true' : '/pages/index/index');
const user = (await r.load('store/user.js')).useUserStore();
assert.equal(user.state.assetPhoneBound, true);
assert.equal(user.state.assetPhoneIsOwn, false);
assert.equal(user.state.needBindPhone, required);
}
});
test('expiry boundaries and Shanghai rendering preserve server values, including zero', async () => {
const r = runtime();
const { normalizeExpiryReminder, getShanghaiDay, formatShanghaiExpiry } = await r.load('utils/expiry-reminder.js');
for (const days of [0, 3, 4, 7, 8, 15]) {
const level = days <= 3 ? 'red' : days <= 7 ? 'purple' : 'pink';
assert.equal(normalizeExpiryReminder({ remaining_days: days, level }).remaining_days, days);
}
for (const value of [undefined, null, {}, { remaining_days: -1, level: 'red' }, { remaining_days: 16, level: 'pink' }]) {
assert.equal(normalizeExpiryReminder(value), null);
}
assert.equal(getShanghaiDay(new Date('2026-10-12T15:59:59Z')), '2026-10-12');
assert.equal(getShanghaiDay(new Date('2026-10-12T16:00:00Z')), '2026-10-13');
assert.match(formatShanghaiExpiry('2026-10-12T16:00:00Z'), /2026.*10.*13.*00:00/);
assert.equal(formatShanghaiExpiry('2026-10-13T12:30:00'), '2026-10-13 12:30:00');
});
test('shared asset-info receiver keeps a child-first reminder and rejects stale sessions/phones', async () => {
const r = runtime();
const { assetApi } = await r.load('api/modules/asset.js');
const user = (await r.load('store/user.js')).useUserStore();
const asset = (await r.load('store/asset.js')).useAssetStore();
const first = assetApi.getInfo('A');
const second = assetApi.getInfo('A');
r.reply(r.requests[0], { bound_phone: '13800000001', expiry_reminder: { remaining_days: 0, level: 'red', expires_at: '2026-10-13T16:00:00Z' } });
await first;
r.reply(r.requests[1], { bound_phone: '13800000001' });
await second;
assert.equal(asset.state.expiryReminder.reminder.remaining_days, 0);
asset.markExpiryReminderShown();
asset.dismissExpiryReminder();
const repeated = assetApi.getInfo('A');
r.reply(r.requests[2], { expiry_reminder: { remaining_days: 0, level: 'red' } });
await repeated;
assert.equal(asset.state.expiryReminder, null);
const oldPhoneResponse = assetApi.getInfo('A');
user.completeCustomerPhoneChange('13700000003');
r.reply(r.requests[3], { bound_phone: '13800000001' });
await oldPhoneResponse;
assert.equal(user.state.customerPhone, '13700000003');
const late = assetApi.getInfo('A');
user.setIdentifier('B');
r.reply(r.requests[4], { bound_phone: '13800000001', expiry_reminder: { remaining_days: 3, level: 'red' } });
await late;
assert.equal(asset.state.expiryReminder, null);
assert.equal(user.state.customerPhone, null);
const b = assetApi.getInfo('B');
r.reply(r.requests[5], { expiry_reminder: { remaining_days: 8, level: 'pink' } });
await b;
assert.equal(asset.state.expiryReminder.identifier, 'B');
});
test('notification APIs use POST read-all and retain PUT single-read without new parameters', async () => {
const r = runtime();
const { notificationApi } = await r.load('api/modules/notification.js');
const all = notificationApi.markAllRead();
assert.equal(r.requests[0].method, 'POST');
assert.match(r.requests[0].url, /\/notifications\/read-all$/);
r.reply(r.requests[0]); await all;
const one = notificationApi.markRead(12);
assert.equal(r.requests[1].method, 'PUT');
assert.deepEqual(plain(r.requests[1].data), { id: 12 });
r.reply(r.requests[1]); await one;
const list = notificationApi.getList(1, 20);
assert.deepEqual(plain(r.requests[2].data), { page: 1, page_size: 20 });
r.reply(r.requests[2]); await list;
});
test('1192 disables repeated submission, while 1184 keeps the backend error', async () => {
for (const code of [1192, 1184]) {
const r = runtime();
const user = (await r.load('store/user.js')).useUserStore();
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: false });
user.setCustomerPhone('13800000001');
let submits = 0;
r.api.authApi = { bindPhone: async () => { submits++; throw { code, msg: '手机号已被其他客户绑定' }; } };
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone', 'assetConflict']);
await r.mounted[0]();
page.bind.phone = '13900000002'; page.bind.code = '123456';
await page.bindPhone();
if (code === 1192) {
await page.bindPhone();
assert.equal(submits, 1);
assert.equal(page.assetConflict.value, true);
assert.match(r.calls.find(call => call.kind === 'modal').content, /联系管理员/);
} else {
assert.equal(page.assetConflict.value, false);
assert.equal(r.calls.find(call => call.kind === 'toast').title, '手机号已被其他客户绑定');
}
assert.equal(user.state.assetPhoneBound, false);
assert.equal(user.state.customerPhone, '13800000001');
}
});
test('asset-only binding accepts a different phone; mandatory binding does not consume asset info', async () => {
const r = runtime();
const user = (await r.load('store/user.js')).useUserStore();
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: false });
user.setCustomerPhone('13800000001');
let submitted;
r.api.authApi = { bindPhone: async (phone, code) => { submitted = { phone, code }; } };
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone', 'bindingMode']);
await r.mounted[0]();
assert.equal(page.bindingMode.value, 'asset');
page.bind.phone = '13900000002'; page.bind.code = '123456';
await page.bindPhone();
assert.deepEqual(submitted, { phone: '13900000002', code: '123456' });
assert.equal(user.state.customerPhone, '13800000001');
assert.equal(user.state.assetPhoneBound, true);
for (const assetBound of [true, false]) {
const mandatory = runtime();
mandatory.setOptions({ fromLogin: 'true' });
const mandatoryUser = (await mandatory.load('store/user.js')).useUserStore();
mandatoryUser.setLoginPhoneState({ asset_phone_bound: assetBound, asset_phone_is_own: false, need_bind_phone: true });
mandatory.api.authApi = { bindPhone: async () => {} };
mandatory.api.assetApi = { getInfo: async () => { throw new Error('Mandatory page must not consume expiry reminder'); } };
const mandatoryPage = await mandatory.load('pages/bind/bind.vue', ['bind', 'bindPhone']);
await mandatory.mounted[0]();
mandatoryPage.bind.phone = '13900000002'; mandatoryPage.bind.code = '123456';
await mandatoryPage.bindPhone();
assert.equal(mandatory.calls.find(call => call.kind === 'reLaunch').url, '/pages/login/login');
assert.equal(mandatory.storage.get('identifier'), 'A');
assert.equal(mandatory.storage.has('token'), false);
}
});
test('change-phone page updates only the confirmed customer phone', async () => {
const r = runtime();
const user = (await r.load('store/user.js')).useUserStore();
user.setLoginPhoneState({ asset_phone_bound: true, asset_phone_is_own: true, need_bind_phone: false });
user.setCustomerPhone('13800000001');
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
r.api.authApi = { changePhone: async () => ({ phone: '13900000002' }) };
const page = await r.load('pages/change-phone/change-phone.vue', ['form', 'submit']);
await r.mounted[0]();
page.form.oldCode = '123456'; page.form.newPhone = '13900000002'; page.form.newCode = '654321';
await page.submit();
assert.equal(user.state.customerPhone, '13900000002');
assert.equal(user.state.assetPhoneBound, true);
assert.equal(user.state.assetPhoneIsOwn, null);
assert.equal(page.form.oldPhone, '13900000002');
assert.equal(r.storage.has('phoneBindStatus'), false);
});
test('home shows shared reminder once and excludes only expiry from old notification popups', async () => {
const r = runtime();
r.api.assetApi = { getInfo: async () => ({ identifier: 'A' }) };
r.api.notificationApi = {
getList: async () => ({ items: [{ id: 1, category: 'expiry', is_read: false }, { id: 2, category: 'approval', is_read: false }] }),
getUnreadCount: async () => ({ count: 2 }), markRead: async () => {}
};
const asset = (await r.load('store/asset.js')).useAssetStore();
asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 0, level: 'red' });
const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'loadUnreadNotifications', 'notificationItems', 'closeExpiryReminder']);
r.shown[0](); await r.tick();
assert.equal(page.visibleExpiryReminder.value.remaining_days, 0);
await page.loadUnreadNotifications();
assert.deepEqual(plain(page.notificationItems.value).map(item => item.id), [2]);
page.closeExpiryReminder();
r.hidden[0](); r.shown[0](); await r.tick();
assert.equal(page.visibleExpiryReminder.value, null);
});
test('notification center refreshes server scope after read-all; failures keep unread items', async () => {
for (const fail of [false, true]) {
const r = runtime();
let readAll = false;
r.api.notificationApi = {
getList: async () => ({ items: [{ id: 1, is_read: readAll, category: 'expiry' }], total: 1 }),
getUnreadCount: async () => ({ count: readAll ? 0 : 1 }),
markAllRead: async () => { if (fail) throw new Error('offline'); readAll = true; }
};
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'loadUnreadCount', 'markAllRead', 'notifications', 'unreadCount']);
await page.loadNotifications(); await page.loadUnreadCount();
await page.markAllRead();
assert.equal(page.notifications[0].is_read, !fail);
assert.equal(page.unreadCount.value, fail ? 1 : 0);
}
});
test('notification center rejects an old asset response after switching context', async () => {
const r = runtime();
const oldList = deferred();
r.api.notificationApi = { getList: () => oldList.promise, getUnreadCount: async () => ({ count: 0 }) };
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'notifications']);
const pending = page.loadNotifications();
r.storage.set('identifier', 'B');
oldList.resolve({ items: [{ id: 1, category: 'expiry' }], total: 1 });
await pending;
assert.equal(page.notifications.length, 0);
});
test('asset A read-all does not locally mark asset B expiry notifications read', async () => {
const r = runtime();
const read = new Map([['A', false], ['B', false]]);
r.api.notificationApi = {
getList: async () => ({ items: [{ id: r.storage.get('identifier'), category: 'expiry', is_read: read.get(r.storage.get('identifier')) }], total: 1 }),
getUnreadCount: async () => ({ count: read.get(r.storage.get('identifier')) ? 0 : 1 }),
markAllRead: async () => read.set(r.storage.get('identifier'), true)
};
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'loadUnreadCount', 'markAllRead', 'notifications', 'unreadCount']);
await page.loadNotifications(); await page.loadUnreadCount(); await page.markAllRead();
assert.equal(page.notifications[0].is_read, true);
r.storage.set('identifier', 'B');
await r.shown[0]();
assert.equal(page.notifications[0].id, 'B');
assert.equal(page.notifications[0].is_read, false);
assert.equal(page.unreadCount.value, 1);
});
test('401 clears phone/reminder state, but late 401 cannot log out a new session', async () => {
const r = runtime();
const request = (await r.load('utils/request.js')).default;
const user = (await r.load('store/user.js')).useUserStore();
const stale = request({ url: '/old' });
user.setToken('session-B');
r.reply(r.requests[0], {}, 1, 401);
await assert.rejects(stale);
assert.equal(user.state.token, 'session-B');
user.setLoginPhoneState({ asset_phone_bound: true, need_bind_phone: false });
const expired = request({ url: '/current' });
r.reply(r.requests[1], {}, 1, 401);
await assert.rejects(expired);
assert.equal(user.state.token, '');
assert.equal(user.state.assetPhoneBound, null);
assert.equal(r.storage.has('customerAssetPhoneState'), false);
});

29
utils/expiry-reminder.js Normal file
View File

@@ -0,0 +1,29 @@
export const getShanghaiDay = (date = new Date()) => {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(date);
const part = (type) => parts.find(item => item.type === type)?.value || '';
return `${part('year')}-${part('month')}-${part('day')}`;
};
export const normalizeExpiryReminder = (reminder) => {
if (!reminder || typeof reminder !== 'object') return null;
const days = reminder.remaining_days;
if (days === null || days === undefined || days === '' ||
!Number.isInteger(Number(days)) || Number(days) < 0 || Number(days) > 15 ||
!['red', 'purple', 'pink'].includes(reminder.level)) return null;
return { ...reminder, remaining_days: Number(days) };
};
export const formatShanghaiExpiry = (value) => {
if (!value) return '-';
const text = String(value);
// An offset-free server date is already a Shanghai wall-clock value.
if (!/(Z|[+-]\d{2}:?\d{2})$/i.test(text)) return text.replace('T', ' ').slice(0, 19);
const date = new Date(text);
if (!Number.isFinite(date.getTime())) return '-';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
}).format(date);
};

View File

@@ -1,25 +1,26 @@
const PHONE_BIND_STATUS_KEY = 'phoneBindStatus';
const readStatuses = () => {
const statuses = uni.getStorageSync(PHONE_BIND_STATUS_KEY);
return statuses && typeof statuses === 'object' && !Array.isArray(statuses) ? statuses : {};
// A customer phone or an old cache does not prove an asset association exists.
export const optionalBoolean = (value) => {
if (value === true || value === 1) return true;
if (value === false || value === 0) return false;
return null;
};
export const getPhoneBindStatus = (identifier) => {
if (!identifier) return null;
const status = readStatuses()[identifier];
return status && status.bound ? status : null;
export const getPhoneBindingMode = (customerPhone, assetPhoneBound) => {
if (customerPhone === null || assetPhoneBound === null) return 'unknown';
if (!customerPhone) return assetPhoneBound ? 'customer' : 'customer-asset';
return assetPhoneBound ? 'none' : 'asset';
};
export const setPhoneBindStatus = (identifier, phone) => {
if (!identifier) return;
const statuses = readStatuses();
statuses[identifier] = {
bound: true,
phone: phone || '',
updatedAt: Date.now()
};
uni.setStorageSync(PHONE_BIND_STATUS_KEY, statuses);
export const getBindingSuccessState = (state, phone, mode) => {
if (mode === 'customer-asset') {
return { customerPhone: phone, assetPhoneBound: true, assetPhoneIsOwn: true, needBindPhone: false };
}
if (mode === 'asset') {
return { assetPhoneBound: true, assetPhoneIsOwn: phone === state.customerPhone, needBindPhone: false };
}
if (mode === 'customer') {
return { customerPhone: phone, assetPhoneIsOwn: null, needBindPhone: false };
}
// An ambiguous success response does not identify which phone was changed.
return { needBindPhone: false };
};

View File

@@ -1,4 +1,5 @@
import { BASE_URL } from './env.js';
import { useUserStore } from '@/store/user.js';
const showErrorToast = (message) => {
uni.showToast({
@@ -26,12 +27,14 @@ const request = (options) => {
const { code, msg, data } = responseData;
if (res.statusCode === 401) {
uni.removeStorageSync('token');
uni.removeStorageSync('identifier');
showErrorToast(msg || '登录已过期,请重新登录');
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/login' });
}, 2000);
// A late response from an old login must not clear a new session.
if (token === (uni.getStorageSync('token') || '')) {
useUserStore().clearUser();
showErrorToast(msg || responseData.message || '登录已过期,请重新登录');
setTimeout(() => {
if (!uni.getStorageSync('token')) uni.reLaunch({ url: '/pages/login/login' });
}, 2000);
}
reject(responseData);
return;
}
@@ -42,7 +45,7 @@ const request = (options) => {
}
if (options.showError !== false) {
showErrorToast(msg);
showErrorToast(msg || responseData.message);
}
reject(responseData);
},

9
utils/session-context.js Normal file
View File

@@ -0,0 +1,9 @@
export const getSessionContext = () => ({
token: uni.getStorageSync('token') || '',
identifier: uni.getStorageSync('identifier') || ''
});
export const isCurrentSession = (context) => {
const current = getSessionContext();
return !!context.token && context.token === current.token && context.identifier === current.identifier;
};