Compare commits
229 Commits
feat/redes
...
iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b650d41e | ||
|
|
535f92b818 | ||
|
|
b6e55d315c | ||
|
|
d13be3580f | ||
|
|
b035105f80 | ||
|
|
c11d1881ef | ||
|
|
06ca8af218 | ||
|
|
0136615502 | ||
|
|
31ff18d66a | ||
|
|
b2f802e6f8 | ||
|
|
1baea2b24f | ||
|
|
e084cdeb90 | ||
|
|
b5f89b744c | ||
|
|
33f3b37be0 | ||
|
|
f7cb28febf | ||
|
|
131823ca12 | ||
|
|
a7b1156ac1 | ||
|
|
d672dec0d0 | ||
|
|
697ff9c33c | ||
|
|
97dec4dc65 | ||
|
|
da95a914e3 | ||
|
|
77b0e25011 | ||
|
|
dc53295e2c | ||
|
|
65f997cbc3 | ||
|
|
e82b0ab5da | ||
|
|
a81b403cce | ||
|
|
c0cc1ae6dd | ||
|
|
8bd9de15cd | ||
|
|
6a1543f9f6 | ||
|
|
010b941d0e | ||
|
|
a25ea5954c | ||
|
|
8b6ce505b5 | ||
|
|
484a807dce | ||
|
|
6845e97b14 | ||
|
|
d3d606b1a7 | ||
|
|
dfc7472ad9 | ||
|
|
515e7e22c8 | ||
|
|
84af596a65 | ||
|
|
e1498cb5a6 | ||
|
|
42d94ce290 | ||
|
|
c22cae9c38 | ||
|
|
a3fe025a88 | ||
|
|
61672a4d51 | ||
|
|
ac45a08373 | ||
|
|
1e649e9835 | ||
|
|
1451cc83ff | ||
|
|
9f07f9d0ef | ||
|
|
1891ea1e0d | ||
|
|
bf0a38c763 | ||
|
|
9f70a7e395 | ||
|
|
d05a47ffe2 | ||
|
|
f9d8a107a4 | ||
|
|
e4278d3883 | ||
|
|
819d5dc2f5 | ||
|
|
4c10d1f9fa | ||
|
|
7de5efedfd | ||
|
|
5f8ff768e6 | ||
|
|
d0f032aa1c | ||
|
|
c45227ba7e | ||
|
|
7ecb4709c8 | ||
|
|
49175f8a73 | ||
|
|
5cc6d4349e | ||
|
|
2866b889bb | ||
|
|
59dde650a2 | ||
|
|
9d0727ad8b | ||
|
|
8f1a731b2b | ||
|
|
39bc9f506d | ||
|
|
0bae5233b3 | ||
|
|
502b2723db | ||
|
|
2fb95b3257 | ||
|
|
dfa68a6a79 | ||
|
|
8027916e7a | ||
|
|
8ef94125b9 | ||
|
|
e7a7b30bc8 | ||
|
|
863a34f74c | ||
|
|
b95daedba6 | ||
|
|
7136655f48 | ||
|
|
b5e4a1259b | ||
|
|
2166241cdb | ||
|
|
6c3cf15165 | ||
|
|
34ca1c5eca | ||
|
|
64bccffe28 | ||
|
|
4f281da778 | ||
|
|
189d64c290 | ||
|
|
6a794d88af | ||
|
|
861ffa371b | ||
|
|
f6a33121f2 | ||
|
|
133a4d18ef | ||
|
|
9c1c296d2e | ||
|
|
3929156ef4 | ||
|
|
ad3c9b964d | ||
|
|
59696a7e8e | ||
|
|
d90a51ebf4 | ||
|
|
5b239c13b7 | ||
|
|
8397a51f8c | ||
|
|
a9fbbea1e6 | ||
|
|
6756ad5700 | ||
|
|
f581ccaa9b | ||
|
|
832c2852c7 | ||
|
|
3e81a216ad | ||
|
|
85cdeac804 | ||
|
|
59e4e1203e | ||
|
|
4dc86889c5 | ||
| 60fa91ea25 | |||
|
|
50e4be4976 | ||
|
|
5d78f24366 | ||
|
|
069eab0462 | ||
|
|
c265dacafd | ||
|
|
83c32279eb | ||
|
|
169d2bdaad | ||
|
|
427b211295 | ||
|
|
874ff41ce3 | ||
|
|
368c0d75e7 | ||
|
|
a0b2f69a8e | ||
|
|
b566b0a5ea | ||
|
|
4d9aa59254 | ||
|
|
1edd299f1b | ||
|
|
f9fead83cf | ||
|
|
dcc8c0ccd6 | ||
|
|
0be325e379 | ||
|
|
fded2fe12d | ||
|
|
b611808316 | ||
|
|
7253996f40 | ||
|
|
276f67b839 | ||
|
|
1ae5d945d1 | ||
|
|
ac1f4b1cec | ||
|
|
3f997063f4 | ||
|
|
c798cdba7f | ||
|
|
5218b37578 | ||
|
|
730626143d | ||
|
|
13f8538941 | ||
|
|
6b3e7ac185 | ||
| 8cec21e77e | |||
| e3f4b05602 | |||
|
|
92c5c4ed50 | ||
|
|
7dfbb2de3d | ||
|
|
ce7e2152b1 | ||
|
|
5f832f26b7 | ||
|
|
d6a9693482 | ||
|
|
9fadbd297b | ||
|
|
52c36dfc9b | ||
|
|
a5c7e46dd7 | ||
|
|
f505d85018 | ||
|
|
7d0296dedb | ||
|
|
83c27e5427 | ||
|
|
8ca0fc3d50 | ||
|
|
a0ac16d86c | ||
| 74d83468e3 | |||
|
|
29928f121a | ||
|
|
e7b9c75ad0 | ||
|
|
cebe545440 | ||
|
|
d679cad55f | ||
|
|
5f0c215bdc | ||
|
|
35424347f3 | ||
|
|
e121fca8ca | ||
|
|
5de611d28f | ||
|
|
24b2293d71 | ||
|
|
fbf5371086 | ||
|
|
f86281017a | ||
|
|
f51c8d912b | ||
|
|
bcc63f508a | ||
|
|
fad37f511c | ||
|
|
a9a1a557d9 | ||
|
|
383e7b819b | ||
|
|
8f4ecda7eb | ||
|
|
71bb404fe1 | ||
|
|
ef77905dbb | ||
|
|
69923b81b0 | ||
|
|
791e034a6b | ||
|
|
42f4d9f96a | ||
|
|
1d6c12e8d7 | ||
|
|
8dc8600b52 | ||
|
|
94a31b2e47 | ||
|
|
8a8393eede | ||
|
|
882ec0a1b8 | ||
|
|
093c337bc4 | ||
|
|
e2007b008a | ||
|
|
372e22f4b7 | ||
|
|
0ada36a778 | ||
|
|
547d944812 | ||
|
|
bdaada8224 | ||
|
|
b3fea2100b | ||
|
|
1e03191e82 | ||
|
|
25fc534619 | ||
|
|
5dc948efec | ||
|
|
fc7305dffe | ||
|
|
35fa5fd2ae | ||
|
|
898aa41fd2 | ||
|
|
22538a8972 | ||
|
|
379ac883f0 | ||
|
|
2d27f55d69 | ||
|
|
62751369a2 | ||
|
|
10f8a1658e | ||
|
|
bbb840e521 | ||
|
|
404daa4b6d | ||
|
|
40b8f76eef | ||
|
|
5f4a26e693 | ||
|
|
f72b77fe0d | ||
|
|
39773341bd | ||
|
|
16a47c1229 | ||
|
|
715e4f6f7d | ||
|
|
b3c925b0ca | ||
|
|
263515758c | ||
|
|
f4ac65acce | ||
|
|
f5f993d1e2 | ||
|
|
63af194ec5 | ||
|
|
6d327c41cd | ||
|
|
bd0bb149b8 | ||
|
|
102e6c0867 | ||
|
|
b56f68636c | ||
|
|
e0db2b6df0 | ||
|
|
25e9946c17 | ||
|
|
64f479c62a | ||
|
|
c1172f7591 | ||
|
|
04ee3f2c87 | ||
|
|
4cb1e65e6e | ||
|
|
89aafa7317 | ||
|
|
e2d3d8172f | ||
|
|
7c35a0bad6 | ||
|
|
bd0fc9cfdb | ||
|
|
cca7131d68 | ||
|
|
0337d86ba2 | ||
|
|
89a6441f62 | ||
|
|
58d4b92287 | ||
|
|
733fcc5511 | ||
|
|
c06d543af3 | ||
|
|
6b066e35da | ||
|
|
41349ac833 | ||
|
|
c6246c023d |
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(npm list:*)",
|
||||
"Bash(find . -type f \\\\\\(-name *.openapi.* -o -name *-spec.yaml -o -name *-spec.yml \\\\\\))"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
dist
|
||||
unpackage
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.env.local
|
||||
.env.*.local
|
||||
1
.env.development
Normal file
1
.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_BASE_URL=https://cmp-api.boss160.cn
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_BASE_URL=https://cmp-api.xm-iot.cn
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.html linguist-detectable=false
|
||||
*.vue linguist-detectable=true
|
||||
@@ -1,9 +1,9 @@
|
||||
name: 构建并部署前端到生产环境
|
||||
name: 构建并部署前端到测试环境
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- iteration/8-11
|
||||
- dev
|
||||
- test
|
||||
|
||||
@@ -21,13 +21,14 @@ jobs:
|
||||
run: |
|
||||
export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||
export GIT_SSL_NO_VERIFY=1
|
||||
git clone https://git.boss160.cn/luo/device-voice-h5.git .
|
||||
git clone https://git.boss160.cn/luo/device-voice-h5 .
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/iteration/8-11" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
@@ -51,8 +52,8 @@ jobs:
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
|
||||
- name: 部署到本地(仅 main 分支)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: 部署到测试环境(仅 iteration/8-11 分支)
|
||||
if: github.ref == 'refs/heads/iteration/8-11'
|
||||
run: |
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}
|
||||
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -2,9 +2,22 @@
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
npminstall-debug.log
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Common local files
|
||||
dist-ssr
|
||||
*.local
|
||||
.cursorrules
|
||||
.claude
|
||||
.codex
|
||||
.env.development
|
||||
.env.production
|
||||
.scratch
|
||||
.agents
|
||||
Thumbs.db
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
@@ -91,4 +104,4 @@ dist/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
||||
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
54
App.vue
54
App.vue
@@ -5,17 +5,20 @@
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import "uview-plus/index.scss";
|
||||
|
||||
:root {
|
||||
/* 主色 */
|
||||
--primary: #0A84FF;
|
||||
--primary-light: #5AC8FA;
|
||||
--primary-dark: #0066CC;
|
||||
--primary: #55ab5c;
|
||||
--primary-light: #8bc98f;
|
||||
--primary-dark: #3f8f47;
|
||||
--up-primary: #55ab5c;
|
||||
--up-primary-light: #eaf6ec;
|
||||
--up-primary-dark: #3f8f47;
|
||||
|
||||
/* 辅助色 */
|
||||
--success: #30D158;
|
||||
--success-light: rgba(48, 209, 88, 0.12);
|
||||
--success: #55ab5c;
|
||||
--success-light: rgba(85, 171, 92, 0.12);
|
||||
--up-success: #55ab5c;
|
||||
--up-success-light: #eaf6ec;
|
||||
--warning: #FF9F0A;
|
||||
--warning-light: rgba(255, 159, 10, 0.12);
|
||||
--danger: #FF453A;
|
||||
@@ -41,7 +44,7 @@
|
||||
|
||||
/* 背景色 */
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #F2F2F7;
|
||||
--bg-secondary: #F8F8F8;
|
||||
--bg-tertiary: #FFFFFF;
|
||||
--bg-grouped: #F2F2F7;
|
||||
|
||||
@@ -95,7 +98,8 @@
|
||||
|
||||
page {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
background: var(--bg-secondary);
|
||||
// min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -105,6 +109,12 @@
|
||||
padding: 30rpx 20rpx;
|
||||
max-width: 750rpx;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pd-20{
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -230,7 +240,7 @@
|
||||
}
|
||||
|
||||
&.tag-primary {
|
||||
background: rgba(10, 132, 255, 0.12);
|
||||
background: rgba(85, 171, 92, 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -284,7 +294,7 @@
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
background: var(--success);
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
@@ -323,6 +333,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 40rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 20rpx;
|
||||
background: var(--gray-100);
|
||||
color: var(--primary);
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -9,25 +9,36 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 设置 npm 镜像源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
# 安装 pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
# 复制 package.json(利用 Docker 缓存)
|
||||
COPY package.json ./
|
||||
# 设置 pnpm 镜像源
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 复制 package.json 和 pnpm-lock.yaml(利用 Docker 缓存)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# 安装所有依赖
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 安装 git (用于获取 commit 信息)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 创建 src 目录软链接 (兼容 uni-app 新版本)
|
||||
RUN mkdir -p src && \
|
||||
ln -sf /build/manifest.json /build/src/manifest.json && \
|
||||
ln -sf /build/pages.json /build/src/pages.json
|
||||
|
||||
# 构建 H5 生产版本
|
||||
RUN npm run build:h5
|
||||
RUN pnpm run build:h5
|
||||
|
||||
# ================================
|
||||
# 阶段 2: 运行阶段
|
||||
# ================================
|
||||
FROM --platform=linux/amd64 nginx:alpine
|
||||
FROM --platform=linux/amd64 nginx:1.31.2-alpine
|
||||
|
||||
# 使用阿里云镜像源加速
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
@@ -46,6 +57,8 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 从构建阶段复制构建产物
|
||||
COPY --from=builder /build/dist/build/h5 /usr/share/nginx/html
|
||||
COPY MP_verify_yU2Z8mp831jh6QX7.txt /usr/share/nginx/html/MP_verify_yU2Z8mp831jh6QX7.txt
|
||||
COPY MP_verify_0UoX8yClVgREjgPj.txt /usr/share/nginx/html/MP_verify_0UoX8yClVgREjgPj.txt
|
||||
COPY MP_verify_4E1dVbOcZ9KzTzoc.txt /usr/share/nginx/html/MP_verify_4E1dVbOcZ9KzTzoc.txt
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
1
MP_verify_0UoX8yClVgREjgPj.txt
Normal file
1
MP_verify_0UoX8yClVgREjgPj.txt
Normal file
@@ -0,0 +1 @@
|
||||
0UoX8yClVgREjgPj
|
||||
1
MP_verify_4E1dVbOcZ9KzTzoc.txt
Normal file
1
MP_verify_4E1dVbOcZ9KzTzoc.txt
Normal file
@@ -0,0 +1 @@
|
||||
4E1dVbOcZ9KzTzoc
|
||||
@@ -1,7 +1,11 @@
|
||||
export { authApi } from './modules/auth.js';
|
||||
export { assetApi } from './modules/asset.js';
|
||||
export { assetApi, hasActiveOrPendingPackage, isAssetRealNameCompleted } from './modules/asset.js';
|
||||
export { deviceApi } from './modules/device.js';
|
||||
export { exchangeApi } from './modules/exchange.js';
|
||||
export { orderApi } from './modules/order.js';
|
||||
export { notificationApi } from './modules/notification.js';
|
||||
export { popupApi } from './modules/popup.js';
|
||||
export { realnameApi } from './modules/realname.js';
|
||||
export { walletApi } from './modules/wallet.js';
|
||||
export { walletApi } from './modules/wallet.js';
|
||||
export { wechatApi } from './modules/wechat.js';
|
||||
export { packageSilenceApi } from './modules/packageSilence.js';
|
||||
|
||||
@@ -1,11 +1,111 @@
|
||||
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';
|
||||
|
||||
let assetInfoRequestId = 0;
|
||||
|
||||
export const normalizeAssetInfo = (data = {}) => {
|
||||
// 保护期已下线:不向展示和业务判断透传废弃字段,兼容 none 或字段缺失。
|
||||
const { device_protect_status, ...info } = data;
|
||||
return {
|
||||
...info,
|
||||
allowed_payment_methods: Array.isArray(data.allowed_payment_methods)
|
||||
? data.allowed_payment_methods
|
||||
: [],
|
||||
real_name_status: data.real_name_status === undefined || data.real_name_status === null
|
||||
? data.real_name_status
|
||||
: Number(data.real_name_status),
|
||||
realname_required: data.realname_required === undefined || data.realname_required === null
|
||||
? data.realname_required
|
||||
: data.realname_required === true || data.realname_required === 1,
|
||||
days_until_final_expiry: data.days_until_final_expiry === null || data.days_until_final_expiry === undefined
|
||||
? data.days_until_final_expiry
|
||||
: Number(data.days_until_final_expiry),
|
||||
is_expiring: data.is_expiring === undefined || data.is_expiring === null
|
||||
? data.is_expiring
|
||||
: data.is_expiring === true || data.is_expiring === 1
|
||||
};
|
||||
};
|
||||
|
||||
const toBool = (value) => value === true || value === 1 || value === 'true';
|
||||
|
||||
// 沉默期对象:总开关关闭或不适用时后端不返回,保持为 null。
|
||||
export const normalizePackageSilence = (silence) => {
|
||||
if (!silence || typeof silence !== 'object') return null;
|
||||
return {
|
||||
...silence,
|
||||
extendable: toBool(silence.extendable),
|
||||
silence_adjust_pending: toBool(silence.silence_adjust_pending),
|
||||
silence_extended_days: Number(silence.silence_extended_days) || 0
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePackageHistoryNode = (node = {}) => ({
|
||||
...node,
|
||||
silence: normalizePackageSilence(node.silence),
|
||||
children: Array.isArray(node.children)
|
||||
? node.children.filter(child => Number(child?.status) !== 4).map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
expand_by_default: node.expand_by_default === true || node.expand_by_default === 1
|
||||
});
|
||||
|
||||
export const normalizeAssetPackageHistory = (data = {}) => ({
|
||||
...data,
|
||||
items: Array.isArray(data.items)
|
||||
? data.items.filter(node => Number(node?.status) !== 4).map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
page: Number(data.page),
|
||||
size: Number(data.size),
|
||||
total: Number(data.total)
|
||||
});
|
||||
|
||||
export const isAssetRealNameCompleted = (assetInfo = {}) => {
|
||||
if (assetInfo.asset_type === 'device') {
|
||||
return (assetInfo.cards || []).some((card) => Number(card?.real_name_status) === 1);
|
||||
}
|
||||
|
||||
return Number(assetInfo.real_name_status) === 1;
|
||||
};
|
||||
|
||||
export const hasActiveOrPendingPackage = async (identifier) => {
|
||||
const [pendingData, activeData] = await Promise.all([
|
||||
assetApi.getPackageHistory(identifier, 1, 1, { status: 0 }),
|
||||
assetApi.getPackageHistory(identifier, 1, 1, { status: 1 })
|
||||
]);
|
||||
|
||||
return [pendingData, activeData].some(data => Array.isArray(data?.items) && data.items.length > 0);
|
||||
};
|
||||
|
||||
export const assetApi = {
|
||||
getInfo(identifier) {
|
||||
identifier = typeof identifier === 'string' ? identifier.trim() : '';
|
||||
if (!identifier) {
|
||||
return Promise.reject({ msg: '缺少资产标识,请重新输入或扫码登录' });
|
||||
}
|
||||
const context = getSessionContext();
|
||||
const userStore = useUserStore();
|
||||
if (userStore.state.loginPending || !context.token) {
|
||||
return Promise.reject({ msg: '登录和微信配置尚未完成,请稍后重试' });
|
||||
}
|
||||
const phoneRevision = userStore.state.phoneRevision;
|
||||
const requestId = identifier === context.identifier ? ++assetInfoRequestId : null;
|
||||
return request({
|
||||
url: '/api/c/v1/asset/info',
|
||||
method: 'GET',
|
||||
data: { identifier }
|
||||
}).then((response) => {
|
||||
const data = normalizeAssetInfo(response);
|
||||
if (identifier === context.identifier && isCurrentSession(context)) {
|
||||
const assetStore = useAssetStore();
|
||||
// 替换整个快照,不合并旧轮询开关;迟到的旧请求不得覆盖新状态。
|
||||
if (requestId === assetInfoRequestId) assetStore.setAssetInfo(data);
|
||||
assetStore.receiveExpiryReminder(context, data.expiry_reminder);
|
||||
if (phoneRevision === userStore.state.phoneRevision && typeof data.bound_phone === 'string') {
|
||||
userStore.setCustomerPhone(data.bound_phone);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -14,14 +114,17 @@ export const assetApi = {
|
||||
url: '/api/c/v1/asset/package-history',
|
||||
method: 'GET',
|
||||
data: { identifier, page, page_size, ...params }
|
||||
});
|
||||
}).then(normalizeAssetPackageHistory);
|
||||
},
|
||||
|
||||
getPackages(identifier) {
|
||||
getPackages(identifier, packageType = '') {
|
||||
return request({
|
||||
url: '/api/c/v1/asset/packages',
|
||||
method: 'GET',
|
||||
data: { identifier }
|
||||
data: {
|
||||
identifier,
|
||||
...(packageType ? { package_type: packageType } : {})
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -32,4 +135,4 @@ export const assetApi = {
|
||||
data: { identifier }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ export const authApi = {
|
||||
return request({
|
||||
url: '/api/c/v1/auth/verify-asset',
|
||||
method: 'POST',
|
||||
data: { identifier }
|
||||
data: { identifier },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
@@ -29,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
|
||||
});
|
||||
},
|
||||
|
||||
@@ -47,4 +49,4 @@ export const authApi = {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
37
api/modules/notification.js
Normal file
37
api/modules/notification.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
export const notificationApi = {
|
||||
getList(page = 1, page_size = 20, is_read) {
|
||||
return request({
|
||||
url: '/api/c/v1/notifications',
|
||||
method: 'GET',
|
||||
data: {
|
||||
page,
|
||||
page_size,
|
||||
...(is_read === undefined ? {} : { is_read })
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getUnreadCount() {
|
||||
return request({
|
||||
url: '/api/c/v1/notifications/unread-count',
|
||||
method: 'GET'
|
||||
});
|
||||
},
|
||||
|
||||
markRead(id) {
|
||||
return request({
|
||||
url: `/api/c/v1/notifications/${id}/read`,
|
||||
method: 'PUT',
|
||||
data: { id }
|
||||
});
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
return request({
|
||||
url: '/api/c/v1/notifications/read-all',
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -21,26 +21,33 @@ export const orderApi = {
|
||||
});
|
||||
},
|
||||
|
||||
create(identifier, package_ids) {
|
||||
create(identifier, package_ids, payment_method) {
|
||||
const data = { identifier, package_ids, payment_method };
|
||||
if (payment_method === 'wechat') {
|
||||
data.app_type = APP_TYPE;
|
||||
}
|
||||
|
||||
return request({
|
||||
url: '/api/c/v1/orders/create',
|
||||
method: 'POST',
|
||||
data: { identifier, package_ids, app_type: APP_TYPE }
|
||||
data,
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
pay(order_id, payment_method, password) {
|
||||
const data = {
|
||||
payment_method,
|
||||
app_type: APP_TYPE
|
||||
};
|
||||
const data = { payment_method };
|
||||
if (payment_method === 'wechat') {
|
||||
data.app_type = APP_TYPE;
|
||||
}
|
||||
if (password) {
|
||||
data.password = password;
|
||||
}
|
||||
return request({
|
||||
url: `/api/c/v1/orders/${order_id}/pay`,
|
||||
method: 'POST',
|
||||
data
|
||||
data,
|
||||
showError: false
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
75
api/modules/packageSilence.js
Normal file
75
api/modules/packageSilence.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import request from '@/utils/request.js';
|
||||
import { APP_TYPE } from '@/utils/env.js';
|
||||
|
||||
// 实名即生效套餐沉默期 H5 接口(后端 Iteration/8-11,/api/c/v1/package-silence)。
|
||||
// 资格、试算、下单关闭默认错误提示,由页面按沉默期错误码给出客户能看懂的文案。
|
||||
const BASE = '/api/c/v1/package-silence';
|
||||
|
||||
export const packageSilenceApi = {
|
||||
getEligibility(package_usage_id) {
|
||||
return request({
|
||||
url: `${BASE}/eligibility`,
|
||||
method: 'GET',
|
||||
data: { package_usage_id },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
quote(package_usage_id, days) {
|
||||
return request({
|
||||
url: `${BASE}/quote`,
|
||||
method: 'GET',
|
||||
data: { package_usage_id, days },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
createOrder({ package_usage_id, days, payment_method, request_id }) {
|
||||
const data = { package_usage_id, days, payment_method, request_id };
|
||||
// 微信支付带上应用类型:后端在微信内直接下 JSAPI 单,返回 pay_config 拉起支付
|
||||
if (payment_method === 'wechat') data.app_type = APP_TYPE;
|
||||
return request({
|
||||
url: `${BASE}/orders`,
|
||||
method: 'POST',
|
||||
data,
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
getOrders(params = {}) {
|
||||
const data = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') data[key] = value;
|
||||
});
|
||||
return request({
|
||||
url: `${BASE}/orders`,
|
||||
method: 'GET',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
getOrder(id) {
|
||||
return request({
|
||||
url: `${BASE}/orders/${id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
},
|
||||
|
||||
// 作废自己的待支付延期订单(后端先查单,已支付则按支付成功办理并拒绝作废)
|
||||
cancelOrder(id) {
|
||||
return request({
|
||||
url: `${BASE}/orders/${id}/cancel`,
|
||||
method: 'POST',
|
||||
data: {},
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
getRecords(package_usage_id) {
|
||||
return request({
|
||||
url: `${BASE}/records`,
|
||||
method: 'GET',
|
||||
data: { package_usage_id }
|
||||
});
|
||||
}
|
||||
};
|
||||
21
api/modules/popup.js
Normal file
21
api/modules/popup.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
export const popupApi = {
|
||||
getCandidate(page, identifier) {
|
||||
return request({
|
||||
url: '/api/c/v1/popup-candidates',
|
||||
method: 'GET',
|
||||
data: { page, identifier },
|
||||
// 资源不可见 / 无候选时静默处理,不弹全局错误提示
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
submitRiskExchangeAddress(assetId, params) {
|
||||
return request({
|
||||
url: `/api/c/v1/risk-exchanges/${assetId}/address`,
|
||||
method: 'POST',
|
||||
data: { asset_id: assetId, ...params }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
export const realnameApi = {
|
||||
getLink(identifier, iccid) {
|
||||
getLink(identifier, iccid, options = {}) {
|
||||
return request({
|
||||
url: '/api/c/v1/realname/link',
|
||||
method: 'GET',
|
||||
data: { identifier, iccid }
|
||||
data: { identifier, iccid },
|
||||
...options
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,10 +11,16 @@ export const walletApi = {
|
||||
},
|
||||
|
||||
recharge(identifier, amount, payment_method) {
|
||||
const data = { identifier, amount, payment_method };
|
||||
if (payment_method === 'wechat') {
|
||||
data.app_type = APP_TYPE;
|
||||
}
|
||||
|
||||
return request({
|
||||
url: '/api/c/v1/wallet/recharge',
|
||||
method: 'POST',
|
||||
data: { identifier, amount, payment_method, app_type: APP_TYPE }
|
||||
data,
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
@@ -59,4 +65,4 @@ export const walletApi = {
|
||||
data: { identifier, page, page_size, ...params }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
18
api/modules/wechat.js
Normal file
18
api/modules/wechat.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
export const wechatApi = {
|
||||
getAppId() {
|
||||
return request({
|
||||
url: '/api/c/v1/wechat/appid',
|
||||
method: 'GET'
|
||||
});
|
||||
},
|
||||
|
||||
getJssdkConfig(url) {
|
||||
return request({
|
||||
url: '/api/c/v1/wechat/jssdk-config',
|
||||
method: 'GET',
|
||||
data: { url }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,32 +2,30 @@
|
||||
<view class="card device-status-card">
|
||||
<view class="card-header mb-md flex-row-sb">
|
||||
<view class="title">设备信息</view>
|
||||
<view class="tag-apple tag-success">{{ getOperator(deviceInfo.currentIccid) }}</view>
|
||||
<view class="tag-apple tag-success">{{ deviceInfo.carrier_name || '-' }}</view>
|
||||
</view>
|
||||
<view class="device-info flex-col-g16">
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">
|
||||
<view class="subtitle">当前卡: {{ deviceInfo.currentIccid }}</view>
|
||||
<view class="subtitle">当前卡号 {{ deviceInfo.currentIccid }}</view>
|
||||
</view>
|
||||
<view class="info-values flex-row-g20" @tap="$emit('authentication')">
|
||||
<view class="tag-apple" :class="isRealName ? 'tag-success' : 'tag-warning'">
|
||||
{{ isRealName ? "已实名" : "未实名" }}
|
||||
{{ isRealName ? '已实名' : '未实名' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">
|
||||
<view class="subtitle">到期时间:{{ deviceInfo.expireDate }}</view>
|
||||
</view>
|
||||
<view class="info-values flex-row-g20">
|
||||
<view class="tag-apple tag-primary">{{ statusText }}</view>
|
||||
<view class="subtitle" v-if="isDevice">IMEI:{{ deviceInfo.imei || '-' }}</view>
|
||||
<view class="subtitle" v-else>客户手机号:{{ deviceInfo.bound_phone || '-' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="device-metrics flex-row-sb mt-md">
|
||||
<view class="metric-item flex-col-center">
|
||||
<view class="caption mb-xs">信号强度</view>
|
||||
<view class="metric-value">{{ getSignalText(deviceInfo.rssi) }}</view>
|
||||
<view class="caption mb-xs">信号判断</view>
|
||||
<view class="metric-value">{{ deviceInfo.signal_quality || '暂无数据' }}</view>
|
||||
</view>
|
||||
<view class="metric-item flex-col-center">
|
||||
<view class="caption mb-xs">设备电量</view>
|
||||
@@ -38,35 +36,26 @@
|
||||
<view class="metric-value">{{ deviceInfo.connCnt }}/{{ deviceInfo.max_clients }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="shouldShowSignalReason(deviceInfo)" class="signal-reason">
|
||||
<text class="signal-reason-label">怀疑原因:</text>
|
||||
<text class="signal-reason-text">{{ deviceInfo.signal_bad_reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
deviceInfo: { type: Object, default: () => ({}) },
|
||||
isRealName: { type: Boolean, default: false },
|
||||
isDevice: { type: Boolean, default: true },
|
||||
opratorList: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
defineEmits(['authentication']);
|
||||
|
||||
const getOperator = (iccid) => {
|
||||
return '中国电信';
|
||||
};
|
||||
|
||||
const getSignalText = (rssi) => rssi || '强';
|
||||
|
||||
const statusText = computed(() => {
|
||||
const statusMap = {
|
||||
1: '在库',
|
||||
2: '已分销',
|
||||
3: '已激活',
|
||||
4: '已停用'
|
||||
};
|
||||
return statusMap[props.deviceInfo.status] || '-';
|
||||
});
|
||||
const shouldShowSignalReason = (deviceInfo) =>
|
||||
['信号较弱', '信号很差', '暂无数据'].includes(deviceInfo?.signal_quality) &&
|
||||
!!deviceInfo?.signal_bad_reason;
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -74,15 +63,45 @@
|
||||
.info-row {
|
||||
padding: var(--space-sm) 0;
|
||||
border-bottom: 1rpx solid var(--gray-200);
|
||||
&:last-child { border-bottom: none; }
|
||||
.info-label { min-width: 200rpx; }
|
||||
.info-values { flex: 1; justify-content: flex-end; }
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
min-width: 200rpx;
|
||||
}
|
||||
|
||||
.info-values {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.device-metrics {
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 20rpx;
|
||||
.metric-item { flex: 1; }
|
||||
|
||||
.metric-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.signal-reason {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.signal-reason-label {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
134
components/ExpiryReminderPopup.vue
Normal file
134
components/ExpiryReminderPopup.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<view class="expiry-overlay" @tap.stop>
|
||||
<view class="expiry-dialog" :class="`expiry-${reminder.level}`" role="dialog"
|
||||
aria-modal="true" aria-label="套餐临期提醒" @keydown.esc="$emit('close')">
|
||||
<view class="reminder-header">
|
||||
<button class="reminder-close" aria-label="关闭临期提醒" @tap="$emit('close')">×</button>
|
||||
<view class="clock-halo" aria-hidden="true"><view class="clock-face"></view></view>
|
||||
<view class="reminder-title">套餐即将到期</view>
|
||||
<view class="remaining-label">当前资产套餐剩余</view>
|
||||
<view class="remaining-days"><text class="day-number">{{ reminder.remaining_days }}</text><text class="day-unit">天</text></view>
|
||||
</view>
|
||||
<view class="reminder-content">
|
||||
<view class="expiry-details">
|
||||
<text class="expiry-label">预计到期时间</text>
|
||||
<text class="expiry-value">{{ formatShanghaiExpiry(reminder.expires_at) }}</text>
|
||||
</view>
|
||||
<view class="reminder-description">为避免套餐到期影响使用,请及时续费。</view>
|
||||
<button class="reminder-confirm" @tap="$emit('renew')">立即续费</button>
|
||||
<button class="reminder-later" @tap="$emit('close')">我知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatShanghaiExpiry } from '@/utils/expiry-reminder.js';
|
||||
defineProps({ reminder: { type: Object, required: true } });
|
||||
defineEmits(['close', 'renew']);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.expiry-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(20, 26, 36, 0.48);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.expiry-dialog {
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
overflow-y: auto;
|
||||
border-radius: 32rpx;
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.reminder-header {
|
||||
position: relative;
|
||||
padding: 40rpx 36rpx 28rpx;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, var(--reminder-tint), var(--bg-primary));
|
||||
}
|
||||
.reminder-close {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 40rpx;
|
||||
line-height: 52rpx;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
}
|
||||
.reminder-close::after, .reminder-confirm::after, .reminder-later::after { border: none; }
|
||||
.clock-halo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
margin: 28rpx auto 22rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--reminder-tint);
|
||||
}
|
||||
.clock-face {
|
||||
position: relative;
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
border: 4rpx solid var(--reminder-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.clock-face::before, .clock-face::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 23rpx;
|
||||
top: 10rpx;
|
||||
width: 4rpx;
|
||||
height: 17rpx;
|
||||
border-radius: 4rpx;
|
||||
background: var(--reminder-color);
|
||||
}
|
||||
.clock-face::after { top: 23rpx; height: 14rpx; transform: rotate(-55deg); transform-origin: top center; }
|
||||
.reminder-title { color: var(--text-primary); font-size: 38rpx; font-weight: 700; }
|
||||
.remaining-label { margin-top: 20rpx; color: var(--text-secondary); font-size: 26rpx; }
|
||||
.remaining-days { margin-top: 8rpx; color: var(--reminder-color); line-height: 1.2; }
|
||||
.day-number { font-size: 88rpx; font-weight: 700; letter-spacing: -3rpx; font-variant-numeric: tabular-nums; }
|
||||
.day-unit { margin-left: 12rpx; font-size: 28rpx; }
|
||||
.reminder-content { padding: 0 36rpx 36rpx; }
|
||||
.expiry-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
padding: 22rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.expiry-label { font-size: 22rpx; color: var(--text-tertiary); }
|
||||
.expiry-value { font-size: 26rpx; color: var(--text-primary); font-weight: 500; }
|
||||
.reminder-description { margin: 22rpx 0 28rpx; color: var(--text-secondary); font-size: 24rpx; line-height: 1.6; text-align: center; }
|
||||
.reminder-confirm {
|
||||
margin: 0;
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
background: var(--reminder-color);
|
||||
}
|
||||
.expiry-red { --reminder-color: #d94040; --reminder-tint: rgba(217, 64, 64, 0.08); }
|
||||
.reminder-later { margin: 12rpx 0 0; padding: 14rpx; font-size: 24rpx; line-height: 1.5; color: var(--text-tertiary); background: transparent; }
|
||||
.expiry-purple { --reminder-color: #8850c4; --reminder-tint: rgba(136, 80, 196, 0.08); }
|
||||
.expiry-pink { --reminder-color: #ce4878; --reminder-tint: rgba(206, 72, 120, 0.08); }
|
||||
</style>
|
||||
@@ -42,8 +42,8 @@
|
||||
gap: 20rpx;
|
||||
|
||||
.service-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.service-content {
|
||||
@@ -66,10 +66,10 @@
|
||||
|
||||
.service-btn {
|
||||
padding: 16rpx 32rpx;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
|
||||
background: #55ab5c;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<template>
|
||||
<view class="card function-card">
|
||||
<view class="card-header">
|
||||
<view class="title">功能菜单</view>
|
||||
<!-- <view class="header-actions">
|
||||
<button class="btn-apple btn-primary" @tap="$emit('sync')">运营商数据同步</button>
|
||||
</view> -->
|
||||
</view>
|
||||
<view class="function-grid">
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'authentication')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
||||
<image class="function-image icon-authentication" src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
||||
</view>
|
||||
<view :class="['function-name', realNameStatus === '已实名' ? 'text-primary' : '']">
|
||||
{{ realNameStatus || '实名认证' }}
|
||||
@@ -18,92 +12,100 @@
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('sync')" role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/data.png" mode="aspectFit" alt="运营数据同步"></image>
|
||||
<image class="function-image icon-data" src="/static/data.png" mode="aspectFit" alt="数据同步"></image>
|
||||
</view>
|
||||
<view class="function-name">运营数据同步</view>
|
||||
<view class="function-name">数据同步</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'package-order')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
||||
<image class="function-image icon-shop" src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
||||
</view>
|
||||
<view class="function-name">套餐订购</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'notifications')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon function-icon-badge">
|
||||
<image class="function-image icon-notification" src="/static/notification.png" mode="aspectFit" alt="站内通知"></image>
|
||||
<view v-if="unreadCount > 0" class="unread-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</view>
|
||||
</view>
|
||||
<view class="function-name">站内通知</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'order-list')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
|
||||
<image class="function-image icon-order" src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
|
||||
</view>
|
||||
<view class="function-name">我的订单</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'back')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/back.png" mode="aspectFit" alt="后台管理"></image>
|
||||
<image class="function-image icon-back" src="/static/back.png" mode="aspectFit" alt="后台管理"></image>
|
||||
</view>
|
||||
<view class="function-name">后台管理</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'switch')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
||||
<image class="function-image icon-change" src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
||||
</view>
|
||||
<view class="function-name">切换运营商</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'asset-package')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/shop.png" mode="aspectFit" alt="资产套餐历史"></image>
|
||||
<image class="function-image icon-asset-package" src="/static/asset-package-history.png" mode="aspectFit" alt="资产套餐"></image>
|
||||
</view>
|
||||
<view class="function-name">资产套餐历史</view>
|
||||
<view class="function-name">历史套餐</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="handleBindPhone" role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
|
||||
<image class="function-image icon-bind-phone" src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
|
||||
</view>
|
||||
<view :class="['function-name', alreadyBindPhone ? 'text-primary' : '']">
|
||||
{{ 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"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
|
||||
<image class="function-image icon-change-phone" src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
|
||||
</view>
|
||||
<view class="function-name">更换手机号</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" v-if="isDevice"
|
||||
<view class="function-item interactive card-interactive"
|
||||
@tap="$emit('enter', 'device-exchange')" role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/change.png" mode="aspectFit" alt="设备换货"></image>
|
||||
<image class="function-image icon-change" src="/static/change-shop.png" mode="aspectFit" alt="换货"></image>
|
||||
</view>
|
||||
<view class="function-name">设备换货</view>
|
||||
<view class="function-name">换货</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'wallet')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/wallet.png" mode="aspectFit" alt="我的钱包"></image>
|
||||
<image class="function-image icon-wallet" src="/static/wallet-home.png" mode="aspectFit" alt="钱包"></image>
|
||||
</view>
|
||||
<view class="function-name">我的钱包</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/recover.png" mode="aspectFit" alt="重启设备"></image>
|
||||
</view>
|
||||
<view class="function-name">重启设备</view>
|
||||
<view class="function-name">{{ walletText }}</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'restart')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/restart.png" mode="aspectFit" alt="恢复出厂"></image>
|
||||
<image class="function-image icon-restart" src="/static/restart.png" mode="aspectFit" alt="重启设备"></image>
|
||||
</view>
|
||||
<view class="function-name">重启设备</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image class="function-image icon-recover" src="/static/recover.png" mode="aspectFit" alt="恢复出厂"></image>
|
||||
</view>
|
||||
<view class="function-name">恢复出厂</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'out')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
|
||||
<image class="function-image icon-out" src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
|
||||
</view>
|
||||
<view class="function-name">退出登录</view>
|
||||
</view>
|
||||
@@ -114,29 +116,56 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
realNameStatus: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
alreadyBindPhone: {
|
||||
assetPhoneBound: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: null
|
||||
},
|
||||
customerPhone: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
isDevice: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
walletBalance: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['enter', 'sync']);
|
||||
|
||||
const formatWalletBalance = (balance) => {
|
||||
const amount = Number(balance);
|
||||
if (!Number.isFinite(amount) || amount === 0) return '0';
|
||||
|
||||
return (amount / 100).toFixed(2).replace(/\.?0+$/, '');
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
</script>
|
||||
@@ -151,8 +180,8 @@
|
||||
|
||||
.function-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12rpx;
|
||||
width: 100%;
|
||||
|
||||
.function-item {
|
||||
@@ -160,11 +189,11 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx;
|
||||
padding: 16rpx 8rpx;
|
||||
|
||||
.function-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -173,14 +202,52 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.function-image {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.icon-authentication,
|
||||
.icon-shop,
|
||||
.icon-back,
|
||||
.icon-change-phone,
|
||||
.icon-wallet,
|
||||
.icon-restart {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.icon-data { transform: scale(1.06); }
|
||||
.icon-notification,
|
||||
.icon-change,
|
||||
.icon-asset-package { transform: scale(1.01); }
|
||||
.icon-order,
|
||||
.icon-recover { transform: scale(0.91); }
|
||||
.icon-bind-phone { transform: scale(0.93); }
|
||||
.icon-out { transform: scale(0.97); }
|
||||
}
|
||||
|
||||
.function-icon-badge { position: relative; }
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -12rpx;
|
||||
min-width: 34rpx;
|
||||
height: 34rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 20rpx;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.function-name {
|
||||
font-size: 28rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-top: 15rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,4 +256,4 @@
|
||||
.text-primary {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
200
components/NotificationPopup.vue
Normal file
200
components/NotificationPopup.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<view v-if="show" class="notification-popup" @tap.stop>
|
||||
<view class="notification-popup-card">
|
||||
<view class="popup-header">
|
||||
<view class="popup-heading">新通知</view>
|
||||
<view class="popup-close" role="button" aria-label="关闭" @tap="$emit('close')">×</view>
|
||||
</view>
|
||||
|
||||
<swiper class="notification-swiper" :current="current" @change="$emit('change', $event)">
|
||||
<swiper-item v-for="item in items" :key="item.id">
|
||||
<view class="notification-content">
|
||||
<view class="notification-title">{{ item.title || '业务通知' }}</view>
|
||||
<view class="notification-severity">{{ getNotificationMeta(item) }}</view>
|
||||
<view class="notification-time">{{ formatDate(item.created_at) }}</view>
|
||||
<scroll-view scroll-y class="notification-body">
|
||||
<text>{{ item.body || '暂无通知内容' }}</text>
|
||||
</scroll-view>
|
||||
<up-button v-if="item.category === 'expiry'" class="renew-button" type="primary" size="small"
|
||||
@tap.stop="$emit('renew', item)">立即续费</up-button>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<view v-if="items.length > 1" class="popup-footer">
|
||||
<view class="popup-swipe-hint" aria-label="左右滑动切换通知">
|
||||
<text class="swipe-arrow">‹</text>
|
||||
<text>左右滑动切换通知</text>
|
||||
<text class="swipe-arrow">›</text>
|
||||
</view>
|
||||
<view class="popup-dots">
|
||||
<view v-for="(_, index) in items" :key="index" class="popup-dot" :class="{ active: index === current }"></view>
|
||||
</view>
|
||||
<view class="popup-counter">{{ current + 1 }}/{{ items.length }} 左右滑动查看</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['close', 'change', 'renew']);
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
};
|
||||
|
||||
const getNotificationMeta = (item) => {
|
||||
const category = {
|
||||
expiry: '套餐临期',
|
||||
exchange: '换货'
|
||||
}[item?.category] || '业务通知';
|
||||
const severity = {
|
||||
info: '提示',
|
||||
warning: '警告',
|
||||
error: '错误',
|
||||
critical: '严重'
|
||||
}[item?.severity];
|
||||
return severity ? `${category} · ${severity}` : category;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notification-popup {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.notification-popup-card {
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx 32rpx 18rpx;
|
||||
}
|
||||
|
||||
.popup-heading {
|
||||
color: var(--text-primary);
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 48rpx;
|
||||
font-weight: 300;
|
||||
line-height: 46rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-swiper { height: 430rpx; }
|
||||
|
||||
.notification-content {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 18rpx 32rpx 28rpx;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notification-severity {
|
||||
margin-top: 10rpx;
|
||||
color: var(--primary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
margin-top: 12rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
box-sizing: border-box;
|
||||
height: 180rpx;
|
||||
margin-top: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
padding: 0 32rpx 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.renew-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.popup-swipe-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
color: var(--primary);
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.swipe-arrow {
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.popup-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.popup-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #d8dce3;
|
||||
}
|
||||
|
||||
.popup-dot.active { width: 28rpx; border-radius: 8rpx; background: var(--primary); }
|
||||
|
||||
.popup-counter {
|
||||
margin-top: 12rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
404
components/PackageHistoryNode.vue
Normal file
404
components/PackageHistoryNode.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<view class="card package-node" :class="{ 'package-node-child': depth > 0, 'relationship-exception': isMasterMissing }">
|
||||
<view class="package-header flex-row-sb">
|
||||
<view class="package-title-wrap">
|
||||
<view class="package-name">{{ node.package_name }}</view>
|
||||
<text v-if="packageTypeName" class="package-type">{{ packageTypeName }}</text>
|
||||
</view>
|
||||
<view class="tag-apple" :class="getStatusClass(node.status)">{{ node.status_name }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isMasterMissing" class="relationship-status">
|
||||
{{ node.relationship_status_name || node.relationship_status }}
|
||||
</view>
|
||||
<view v-if="hasMasterUsageId" class="relationship-meta">
|
||||
关联主套餐使用记录:{{ node.master_usage_id }}
|
||||
</view>
|
||||
|
||||
<view class="package-info">
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">购买时间</view>
|
||||
<view class="info-value">{{ formatDate(node.created_at) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">激活时间</view>
|
||||
<view class="info-value">{{ formatDate(node.activated_at) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">到期时间</view>
|
||||
<view class="info-value">{{ formatDate(node.expires_at) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="silence" class="silence-info">
|
||||
<view class="silence-header flex-row-sb">
|
||||
<view class="silence-title">实名沉默期</view>
|
||||
<view class="silence-status" :class="getSilenceStatusClass(silence.silence_status)">
|
||||
{{ silence.silence_status_name || '-' }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">截止时间</view>
|
||||
<view class="info-value">{{ formatSilenceDate(silence.silence_deadline_at) }}</view>
|
||||
</view>
|
||||
<view v-if="silence.silence_period_days" class="info-row flex-row-sb">
|
||||
<view class="info-label">沉默期天数</view>
|
||||
<view class="info-value">{{ silence.silence_period_days }} 天</view>
|
||||
</view>
|
||||
<view v-if="silence.silence_extended_days > 0" class="info-row flex-row-sb">
|
||||
<view class="info-label">累计延期</view>
|
||||
<view class="info-value">{{ silence.silence_extended_days }} 天</view>
|
||||
</view>
|
||||
<view v-if="silence.silence_adjust_pending" class="silence-pending">调整审批中</view>
|
||||
<view class="silence-actions">
|
||||
<view
|
||||
class="silence-link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@tap.stop="openSilenceRecords"
|
||||
>沉默期记录</view>
|
||||
<view
|
||||
v-if="silence.extendable"
|
||||
class="silence-extend"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@tap.stop="openSilenceExtension"
|
||||
>延期</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<view class="flow-info">
|
||||
<view class="flow-title">流量信息</view>
|
||||
<view class="flow-stats">
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">已使用</view>
|
||||
<view class="flow-value">{{ getUsedFlow(node) }}</view>
|
||||
</view>
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">总流量</view>
|
||||
<view class="flow-value">{{ getTotalFlow(node) }}</view>
|
||||
</view>
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">剩余</view>
|
||||
<view class="flow-value">{{ getRemainFlow(node) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-section">
|
||||
<view class="progress-apple">
|
||||
<view class="progress-fill" :style="{ width: getUsagePercent(node) }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="hasChildren"
|
||||
class="expand-control"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="String(expanded)"
|
||||
@tap.stop="toggleExpanded"
|
||||
>
|
||||
<text>{{ expanded ? '收起关联加油包' : '展开关联加油包' }}</text>
|
||||
<text class="expand-count">({{ children.length }})</text>
|
||||
</view>
|
||||
|
||||
<view v-if="expanded && hasChildren" class="child-list">
|
||||
<PackageHistoryNode
|
||||
v-for="(child, index) in children"
|
||||
:key="getNodeKey(child, index)"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { formatSilenceDate, getSilenceStatusClass } from '@/utils/silence.js';
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const silence = computed(() => props.node.silence || null);
|
||||
const openSilenceExtension = () => {
|
||||
if (!silence.value?.extendable || !props.node.package_usage_id) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/silence-extension/silence-extension?package_usage_id=${props.node.package_usage_id}`
|
||||
});
|
||||
};
|
||||
const openSilenceRecords = () => {
|
||||
if (!props.node.package_usage_id) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/silence-records/silence-records?package_usage_id=${props.node.package_usage_id}`
|
||||
});
|
||||
};
|
||||
const children = computed(() => Array.isArray(props.node.children) ? props.node.children : []);
|
||||
const hasChildren = computed(() => children.value.length > 0);
|
||||
const isMasterMissing = computed(() => props.node.relationship_status === 'master_missing');
|
||||
const hasMasterUsageId = computed(() => (
|
||||
props.node.master_usage_id !== null && props.node.master_usage_id !== undefined
|
||||
));
|
||||
const packageTypeName = computed(() => ({
|
||||
formal: '正式套餐',
|
||||
addon: '加油包'
|
||||
}[props.node.package_type] || props.node.package_type || ''));
|
||||
const expanded = ref(props.node.expand_by_default === true);
|
||||
|
||||
const getStatusClass = (status) => ({
|
||||
0: 'tag-warning',
|
||||
1: 'tag-success',
|
||||
2: 'tag-primary',
|
||||
3: 'tag-secondary',
|
||||
4: 'tag-danger'
|
||||
}[Number(status)] || '');
|
||||
|
||||
const toNumber = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
};
|
||||
|
||||
const formatMB = (value) => {
|
||||
const mb = toNumber(value);
|
||||
if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const getUsedAmount = (item) => item.enable_virtual_data
|
||||
? toNumber(item.virtual_used_mb)
|
||||
: toNumber(item.real_used_mb);
|
||||
|
||||
const getUsedFlow = (item) => formatMB(getUsedAmount(item));
|
||||
const getTotalFlow = (item) => formatMB(item.real_total_mb);
|
||||
const getRemainFlow = (item) => formatMB(Math.max(toNumber(item.real_total_mb) - getUsedAmount(item), 0));
|
||||
|
||||
const getUsagePercent = (item) => {
|
||||
const total = toNumber(item.real_total_mb);
|
||||
if (!total) return '0%';
|
||||
return `${Math.min((getUsedAmount(item) / total) * 100, 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatDate = (value) => value ? String(value).replace('T', ' ').slice(0, 19) : '-';
|
||||
const getNodeKey = (item, index) => item.package_usage_id ?? `${props.depth}-${index}`;
|
||||
const toggleExpanded = () => {
|
||||
expanded.value = !expanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.silence-info {
|
||||
margin-top: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: 16rpx;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.silence-header {
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.silence-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.silence-status {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
|
||||
&.silence-waiting {
|
||||
color: #0a84ff;
|
||||
background: rgba(10, 132, 255, 0.12);
|
||||
}
|
||||
|
||||
&.silence-expired {
|
||||
color: var(--danger);
|
||||
background: var(--danger-light);
|
||||
}
|
||||
|
||||
&.silence-ended {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.silence-pending {
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 24rpx;
|
||||
color: #ff9f0a;
|
||||
}
|
||||
|
||||
.silence-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 24rpx;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.silence-link {
|
||||
padding: 10rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.silence-extend {
|
||||
padding: 10rpx 32rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.package-node {
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
&.package-node-child {
|
||||
margin: var(--space-sm) 0 0;
|
||||
border-left: 6rpx solid var(--primary-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.relationship-exception {
|
||||
border-left: 6rpx solid var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.package-header {
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.package-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.package-type,
|
||||
.relationship-meta {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.relationship-status {
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--danger-light, #fff2f0);
|
||||
color: var(--danger);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.relationship-meta {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.package-info .info-row {
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
margin: var(--space-md) 0;
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
margin-bottom: var(--space-sm);
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.flow-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flow-label {
|
||||
margin-bottom: 4rpx;
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.flow-value {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-apple {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-small);
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
}
|
||||
|
||||
.expand-control {
|
||||
display: inline-flex;
|
||||
margin-top: var(--space-md);
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.expand-count {
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
|
||||
.child-list {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.tag-secondary {
|
||||
background: var(--gray-200) !important;
|
||||
color: var(--gray-600) !important;
|
||||
}
|
||||
</style>
|
||||
509
components/PopupCandidate.vue
Normal file
509
components/PopupCandidate.vue
Normal file
@@ -0,0 +1,509 @@
|
||||
<template>
|
||||
<view v-if='visible' class='popup-candidate' @tap.stop>
|
||||
<view class='popup-candidate-card'>
|
||||
<template v-if='isCandidateStep'>
|
||||
<view class='popup-header'>
|
||||
<view class='popup-heading'>{{ candidate?.title || '提示' }}</view>
|
||||
<view class='popup-close' role='button' aria-label='关闭' @tap='dismiss'>×</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class='popup-body'>
|
||||
<text>{{ candidate?.body || '' }}</text>
|
||||
</scroll-view>
|
||||
<view class='popup-actions'>
|
||||
<up-button v-if='isRiskExchange' type='primary' @tap='goToAddress'>申请换卡</up-button>
|
||||
<up-button v-else-if='actionLabel' type='primary' @tap='handleAction'>{{ actionLabel }}</up-button>
|
||||
<up-button plain @tap='dismiss'>稍后处理</up-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else-if='isAddressStep'>
|
||||
<view class='popup-header'>
|
||||
<view class='popup-heading'>换货收货信息</view>
|
||||
<view class='popup-close' role='button' aria-label='关闭' @tap='dismiss'>×</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class='popup-address-form'>
|
||||
<view class='form-item'>
|
||||
<view class='form-label'>粘贴收货信息</view>
|
||||
<up-textarea
|
||||
v-model='addressForm.raw_shipping_text'
|
||||
placeholder='可粘贴包含收件人、电话、地址的一整段文字'
|
||||
autoHeight
|
||||
:maxlength='-1'
|
||||
border='surround'
|
||||
/>
|
||||
<view class='form-helper'>识别结果可继续手动修改</view>
|
||||
<view class='paste-action-row'>
|
||||
<button class='btn-apple btn-secondary paste-action-btn' @tap='clearPastedText'>清除</button>
|
||||
<button class='btn-apple btn-secondary paste-action-btn' @tap='fillFromPastedText'>识别并填充</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class='form-item'>
|
||||
<view class='form-label'>收件人姓名</view>
|
||||
<up-input v-model='addressForm.recipient_name' placeholder='请输入收件人姓名' border='surround' maxlength='50'/>
|
||||
</view>
|
||||
<view class='form-item'>
|
||||
<view class='form-label'>收件人电话</view>
|
||||
<up-input v-model='addressForm.recipient_phone' type='number' placeholder='请输入11位手机号' border='surround' maxlength='11'/>
|
||||
</view>
|
||||
<view class='form-item'>
|
||||
<view class='form-label'>所在地区</view>
|
||||
<view class='region-trigger' @tap='openAreaPicker'>
|
||||
<text class='region-trigger-text' :class='{ placeholder: !regionDisplayText }'>
|
||||
{{ regionDisplayText || '请选择省 / 市 / 区' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class='form-item'>
|
||||
<view class='form-label'>详细地址</view>
|
||||
<up-textarea
|
||||
v-model='addressForm.recipient_address_detail'
|
||||
placeholder='请输入详细收货地址'
|
||||
autoHeight
|
||||
:maxlength='-1'
|
||||
border='surround'
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class='popup-address-actions'>
|
||||
<up-button plain @tap='dismiss'>取消</up-button>
|
||||
<up-button type='primary' :loading='submitting' :disabled='submitting' @tap='submitAddress'>提交</up-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else-if='isDoneStep'>
|
||||
<view class='popup-header'>
|
||||
<view class='popup-heading'>申请已提交</view>
|
||||
</view>
|
||||
<view class='result-item'>
|
||||
<view class='result-label'>换货单号</view>
|
||||
<view class='result-value'>{{ exchange?.exchange_no || '-' }}</view>
|
||||
</view>
|
||||
<view class='result-item'>
|
||||
<view class='result-label'>状态</view>
|
||||
<view class='result-value'>{{ exchange?.status_name || '-' }}</view>
|
||||
</view>
|
||||
<view v-if='exchange?.recipient_address' class='result-item'>
|
||||
<view class='result-label'>收货地址</view>
|
||||
<view class='result-value'>{{ exchange.recipient_address }}</view>
|
||||
</view>
|
||||
<view class='popup-actions'>
|
||||
<up-button type='primary' @tap='finish'>完成</up-button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<up-picker
|
||||
:show='showAreaPicker'
|
||||
:columns='areaPickerColumns'
|
||||
keyName='label'
|
||||
title='选择所在地区'
|
||||
:defaultIndex='areaPickerDefaultIndex'
|
||||
closeOnClickOverlay
|
||||
@confirm='confirmAreaPicker'
|
||||
@change='handleAreaPickerChange'
|
||||
@cancel='showAreaPicker = false'
|
||||
@close='showAreaPicker = false'
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { popupApi, notificationApi } from '@/api/index.js';
|
||||
import { handledPopupNotificationIds } from '@/utils/popup.js';
|
||||
import {
|
||||
buildRegionColumns,
|
||||
composeRecipientAddress,
|
||||
getRegionDisplayText,
|
||||
parseShippingText
|
||||
} from '@/utils/shippingAddress.js';
|
||||
|
||||
const PHONE_REG = /^1[3-9]\d{9}$/;
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
identifier: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const visible = ref(false);
|
||||
const step = ref('candidate');
|
||||
const candidate = ref(null);
|
||||
const exchange = ref(null);
|
||||
const submitting = ref(false);
|
||||
const showAreaPicker = ref(false);
|
||||
const areaPickerColumns = ref([]);
|
||||
const areaPickerDefaultIndex = ref([0, 0, 0]);
|
||||
const addressForm = reactive({
|
||||
raw_shipping_text: '',
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
recipient_region_codes: [],
|
||||
recipient_address_detail: ''
|
||||
});
|
||||
|
||||
const isCandidateStep = computed(() => step.value === 'candidate');
|
||||
const isAddressStep = computed(() => step.value === 'address');
|
||||
const isDoneStep = computed(() => step.value === 'done');
|
||||
const isRiskExchange = computed(() => candidate.value?.popup_type === 'risk_exchange');
|
||||
const regionDisplayText = computed(() => getRegionDisplayText(addressForm.recipient_region_codes));
|
||||
|
||||
const actionLabelMap = {
|
||||
package_purchase: '立即购买',
|
||||
asset_wallet_recharge: '立即充值'
|
||||
};
|
||||
|
||||
const actionRouteMap = {
|
||||
package_purchase: '/pages/package-order/package-order',
|
||||
asset_wallet_recharge: '/pages/my-wallet/my-wallet'
|
||||
};
|
||||
|
||||
const actionLabel = computed(() => actionLabelMap[candidate.value?.action_type] || '');
|
||||
|
||||
const goToAddress = () => {
|
||||
step.value = 'address';
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
addressForm.raw_shipping_text = '';
|
||||
addressForm.recipient_name = '';
|
||||
addressForm.recipient_phone = '';
|
||||
addressForm.recipient_region_codes = [];
|
||||
addressForm.recipient_address_detail = '';
|
||||
};
|
||||
|
||||
const syncAreaPickerState = (regionCodes = []) => {
|
||||
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
|
||||
areaPickerColumns.value = columns;
|
||||
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
|
||||
};
|
||||
|
||||
const openAreaPicker = () => {
|
||||
syncAreaPickerState(addressForm.recipient_region_codes);
|
||||
showAreaPicker.value = true;
|
||||
};
|
||||
|
||||
const handleAreaPickerChange = (event) => {
|
||||
const selectedValues = event.value || [];
|
||||
const selectedProvinceCode = selectedValues[0]?.value || '';
|
||||
const selectedCityCode = event.columnIndex === 0 ? '' : selectedValues[1]?.value || '';
|
||||
const selectedDistrictCode = event.columnIndex === 2 ? selectedValues[2]?.value || '' : '';
|
||||
const { columns, defaultIndex } = buildRegionColumns([
|
||||
selectedProvinceCode,
|
||||
selectedCityCode,
|
||||
selectedDistrictCode
|
||||
]);
|
||||
areaPickerColumns.value = columns;
|
||||
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
|
||||
};
|
||||
|
||||
const confirmAreaPicker = ({ value }) => {
|
||||
const selectedCodes = (value || []).map((item) => item?.value).filter(Boolean);
|
||||
if (selectedCodes.length === 3) {
|
||||
addressForm.recipient_region_codes = selectedCodes;
|
||||
syncAreaPickerState(selectedCodes);
|
||||
}
|
||||
showAreaPicker.value = false;
|
||||
};
|
||||
|
||||
const fillFromPastedText = () => {
|
||||
if (!addressForm.raw_shipping_text.trim()) {
|
||||
uni.showToast({ title: '请先粘贴收货信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseShippingText(addressForm.raw_shipping_text);
|
||||
let updatedCount = 0;
|
||||
if (parsed.recipient_name) {
|
||||
addressForm.recipient_name = parsed.recipient_name;
|
||||
updatedCount += 1;
|
||||
}
|
||||
if (parsed.recipient_phone) {
|
||||
addressForm.recipient_phone = parsed.recipient_phone;
|
||||
updatedCount += 1;
|
||||
}
|
||||
if (parsed.regionCodes.length === 3) {
|
||||
addressForm.recipient_region_codes = parsed.regionCodes;
|
||||
syncAreaPickerState(parsed.regionCodes);
|
||||
updatedCount += 1;
|
||||
}
|
||||
if (parsed.detailAddress) {
|
||||
addressForm.recipient_address_detail = parsed.detailAddress;
|
||||
updatedCount += 1;
|
||||
}
|
||||
|
||||
if (!updatedCount) {
|
||||
uni.showToast({ title: '未识别到有效收货信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.showToast({ title: '已识别并填充', icon: 'success' });
|
||||
};
|
||||
|
||||
const clearPastedText = () => {
|
||||
addressForm.raw_shipping_text = '';
|
||||
};
|
||||
|
||||
const markPopupRead = async () => {
|
||||
const id = candidate.value?.notification_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
await notificationApi.markRead(id);
|
||||
} catch (e) {
|
||||
console.error('标记弹窗通知已读失败', e);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
visible.value = false;
|
||||
showAreaPicker.value = false;
|
||||
markPopupRead();
|
||||
emit('close', 'dismissed');
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
const url = actionRouteMap[candidate.value?.action_type];
|
||||
visible.value = false;
|
||||
markPopupRead();
|
||||
emit('close', 'action');
|
||||
if (url) {
|
||||
uni.navigateTo({ url });
|
||||
}
|
||||
};
|
||||
|
||||
const submitAddress = async () => {
|
||||
if (submitting.value) return;
|
||||
|
||||
const recipientName = addressForm.recipient_name.trim();
|
||||
const recipientPhone = addressForm.recipient_phone.trim();
|
||||
const detailAddress = addressForm.recipient_address_detail.trim();
|
||||
if (!recipientName) {
|
||||
uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!recipientPhone) {
|
||||
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!PHONE_REG.test(recipientPhone)) {
|
||||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (addressForm.recipient_region_codes.length !== 3) {
|
||||
uni.showToast({ title: '请选择省市区', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!detailAddress) {
|
||||
uni.showToast({ title: '请输入详细收货地址', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const recipientAddress = composeRecipientAddress(addressForm.recipient_region_codes, detailAddress);
|
||||
|
||||
const assetId = Number(candidate.value?.asset_id);
|
||||
if (!Number.isInteger(assetId) || assetId <= 0) {
|
||||
uni.showToast({ title: '资产信息无效', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
exchange.value = await popupApi.submitRiskExchangeAddress(assetId, {
|
||||
recipient_name: recipientName,
|
||||
recipient_phone: recipientPhone,
|
||||
recipient_address: recipientAddress
|
||||
});
|
||||
await markPopupRead();
|
||||
step.value = 'done';
|
||||
} catch (e) {
|
||||
console.error('提交风险换卡收货地址失败', e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
visible.value = false;
|
||||
showAreaPicker.value = false;
|
||||
emit('close', 'done');
|
||||
};
|
||||
|
||||
let mounted = true;
|
||||
onUnmounted(() => { mounted = false; });
|
||||
onMounted(async () => {
|
||||
if (!props.identifier) {
|
||||
emit('close', 'empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await popupApi.getCandidate(props.page, props.identifier);
|
||||
if (!mounted) return;
|
||||
const item = (data && data.candidate) || null;
|
||||
if (!item || !item.notification_id || handledPopupNotificationIds.has(item.notification_id)) {
|
||||
emit('close', 'empty');
|
||||
return;
|
||||
}
|
||||
|
||||
handledPopupNotificationIds.add(item.notification_id);
|
||||
candidate.value = item;
|
||||
step.value = 'candidate';
|
||||
resetForm();
|
||||
visible.value = true;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// 资源不可见(400/code=1180)与接口异常统一静默关闭,不做 UI 分支
|
||||
console.error('查询弹窗候选失败', e);
|
||||
emit('close', 'invisible');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang='scss'>
|
||||
.popup-candidate {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.popup-candidate-card {
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx 32rpx 18rpx;
|
||||
}
|
||||
|
||||
.popup-heading {
|
||||
color: var(--text-primary);
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 48rpx;
|
||||
font-weight: 300;
|
||||
line-height: 46rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
box-sizing: border-box;
|
||||
max-height: 420rpx;
|
||||
padding: 12rpx 32rpx 24rpx;
|
||||
color: var(--text-secondary);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 10rpx;
|
||||
color: var(--text-primary);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.form-helper {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.popup-address-form {
|
||||
box-sizing: border-box;
|
||||
max-height: 60vh;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.paste-action-row,
|
||||
.popup-address-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.paste-action-row {
|
||||
margin-top: 16rpx;
|
||||
|
||||
.paste-action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.region-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 84rpx;
|
||||
padding: 0 24rpx;
|
||||
border: 2rpx solid var(--border-light);
|
||||
border-radius: 12rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.region-trigger-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.popup-address-actions {
|
||||
padding: 16rpx 32rpx 32rpx;
|
||||
|
||||
:deep(.u-button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 8rpx 32rpx;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
margin-top: 4rpx;
|
||||
color: var(--text-primary);
|
||||
font-size: 28rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
522
components/RenewalPaymentPopup.vue
Normal file
522
components/RenewalPaymentPopup.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<up-popup :show="show" mode="center" @close="close">
|
||||
<view class="renewal-popup">
|
||||
<view class="popup-header">
|
||||
<view class="popup-title">{{ popupTitle }}</view>
|
||||
<view class="popup-close" @tap="close">×</view>
|
||||
</view>
|
||||
|
||||
<view class="package-summary">
|
||||
<view class="summary-name">{{ summaryName }}</view>
|
||||
<view v-if="summaryPrice" class="summary-price">¥{{ summaryPrice }}</view>
|
||||
</view>
|
||||
|
||||
<view class="payment-methods">
|
||||
<view
|
||||
v-for="method in paymentMethodOptions"
|
||||
:key="method.value"
|
||||
class="method-item"
|
||||
:class="{ active: paymentMethod === method.value }"
|
||||
@tap="selectPaymentMethod(method.value)"
|
||||
>
|
||||
<view class="method-left">
|
||||
<image v-if="method.value === 'alipay'" class="method-icon" src="/static/支付宝支付.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wallet'" class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wechat'" class="method-icon" src="/static/微信支付.png" mode="aspectFit"></image>
|
||||
<text class="method-name">{{ method.label }}</text>
|
||||
</view>
|
||||
<text v-if="operationMode === 'order-payment'" class="method-locked-label">订单支付方式</text>
|
||||
<view v-else class="method-radio" :class="{ checked: paymentMethod === method.value }"></view>
|
||||
</view>
|
||||
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="btn-apple btn-secondary" @tap="close">取消</button>
|
||||
<button class="btn-apple btn-primary" :disabled="submitting" @tap="confirmPay">
|
||||
{{ submitting ? '处理中...' : confirmText }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</up-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { assetApi, isAssetRealNameCompleted, orderApi } from '@/api/index.js';
|
||||
import {
|
||||
handlePaymentError,
|
||||
isValidAlipayPaymentLink,
|
||||
isValidWechatPayConfig,
|
||||
markPendingPaymentRefresh,
|
||||
openAlipayPayment,
|
||||
PAYMENT_REFRESH_TARGETS,
|
||||
showPaymentToast,
|
||||
wechatH5Pay
|
||||
} from '@/utils/payment.js';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js';
|
||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||
|
||||
const props = defineProps({
|
||||
identifier: { type: String, default: '' },
|
||||
paymentRefreshTarget: { type: String, default: PAYMENT_REFRESH_TARGETS.ORDER_LIST }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['completed', 'visibility-change']);
|
||||
const show = ref(false);
|
||||
watch(show, (value) => emit('visibility-change', value), { flush: 'sync' });
|
||||
const submitting = ref(false);
|
||||
const packageIds = ref([]);
|
||||
const packageNames = ref([]);
|
||||
const renewalPrice = ref(null);
|
||||
const paymentMethod = ref('alipay');
|
||||
const allowedPaymentMethods = ref([]);
|
||||
const assetInfo = ref({});
|
||||
const operationMode = ref('renewal');
|
||||
const orderPaymentId = ref(null);
|
||||
const paymentMethodOptions = computed(() => getPaymentMethodOptions(allowedPaymentMethods.value));
|
||||
const packageNamesText = computed(() => packageNames.value.filter(Boolean).join('、') || '当前套餐');
|
||||
const popupTitle = computed(() => operationMode.value === 'order-payment' ? '立即支付' : '立即续费');
|
||||
const summaryName = computed(() => operationMode.value === 'order-payment'
|
||||
? packageNamesText.value.replace(/^当前套餐$/, '当前订单')
|
||||
: packageNamesText.value);
|
||||
const summaryPrice = computed(() => renewalPrice.value === null || renewalPrice.value === undefined || renewalPrice.value === ''
|
||||
? ''
|
||||
: formatMoney(renewalPrice.value));
|
||||
const confirmText = computed(() => operationMode.value === 'order-payment' ? '确认支付' : '确认续费');
|
||||
|
||||
const hasPreparedPaymentData = (paymentData) => {
|
||||
return isValidWechatPayConfig(paymentData?.pay_config) ||
|
||||
isValidAlipayPaymentLink(paymentData?.payment_link);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!submitting.value) show.value = false;
|
||||
};
|
||||
|
||||
const loadAssetInfo = async () => {
|
||||
assetInfo.value = await assetApi.getInfo(props.identifier);
|
||||
allowedPaymentMethods.value = normalizePaymentMethods(assetInfo.value.allowed_payment_methods);
|
||||
paymentMethod.value = getDefaultPaymentMethod(allowedPaymentMethods.value);
|
||||
};
|
||||
|
||||
const open = async (ids, names = [], price = null) => {
|
||||
operationMode.value = 'renewal';
|
||||
orderPaymentId.value = null;
|
||||
renewalPrice.value = price;
|
||||
const validIds = [...new Set((Array.isArray(ids) ? ids : [ids])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0))];
|
||||
if (!validIds.length) {
|
||||
uni.showToast({ title: '当前暂无可续费套餐', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!props.identifier) {
|
||||
uni.showToast({ title: '未找到当前资产', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
packageIds.value = validIds;
|
||||
packageNames.value = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
try {
|
||||
await loadAssetInfo();
|
||||
} catch (error) {
|
||||
console.error('加载续费支付方式失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '加载支付方式失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetInfo.value.effective_realname_policy === 'before_order' &&
|
||||
assetInfo.value.realname_required && !isAssetRealNameCompleted(assetInfo.value)) {
|
||||
uni.showModal({
|
||||
title: '需要实名认证',
|
||||
content: '当前资产下单前需要完成实名认证',
|
||||
confirmText: '去认证',
|
||||
cancelText: '取消',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) uni.navigateTo({ url: '/pages/auth/auth' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentMethodOptions.value.length) {
|
||||
uni.showToast({ title: '暂无可用支付方式', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
show.value = true;
|
||||
};
|
||||
|
||||
const openOrderPayment = (orderId, names = [], method = '') => {
|
||||
if (!orderId) return;
|
||||
const selectedMethod = normalizePaymentMethods([String(method || '').trim().toLowerCase()])[0];
|
||||
if (!selectedMethod) {
|
||||
uni.showToast({ title: '订单支付方式无效,请刷新订单后重试', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
operationMode.value = 'order-payment';
|
||||
orderPaymentId.value = orderId;
|
||||
renewalPrice.value = null;
|
||||
packageIds.value = [];
|
||||
packageNames.value = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
allowedPaymentMethods.value = [selectedMethod];
|
||||
paymentMethod.value = selectedMethod;
|
||||
show.value = true;
|
||||
};
|
||||
|
||||
const selectPaymentMethod = (method) => {
|
||||
if (operationMode.value === 'order-payment') return;
|
||||
if (paymentMethodOptions.value.some((item) => item.value === method)) {
|
||||
paymentMethod.value = method;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrderStatus = async (orderId) => {
|
||||
try {
|
||||
const detail = await orderApi.getDetail(orderId);
|
||||
const status = detail?.payment_status ?? detail?.order?.payment_status;
|
||||
showPaymentToast(status === 2, status === 2 ? '支付成功' : '支付结果确认中');
|
||||
if (status === 2) emit('completed');
|
||||
} catch (error) {
|
||||
console.error('确认订单支付状态失败', error);
|
||||
showPaymentToast(false, '支付结果确认失败,请稍后查看订单');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreparedPayment = async (paymentData, orderId = null, isRecharge = false) => {
|
||||
if (isValidWechatPayConfig(paymentData?.pay_config)) {
|
||||
try {
|
||||
await wechatH5Pay(paymentData.pay_config);
|
||||
if (orderId) {
|
||||
await confirmOrderStatus(orderId);
|
||||
} else {
|
||||
showPaymentToast(true, isRecharge ? '充值成功,套餐将自动购买' : '支付成功');
|
||||
emit('completed');
|
||||
}
|
||||
} catch (error) {
|
||||
handlePaymentError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidAlipayPaymentLink(paymentData?.payment_link)) {
|
||||
await openAlipayPayment(paymentData.payment_link, props.paymentRefreshTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||
};
|
||||
|
||||
const handleRechargeOrder = async (orderResult) => {
|
||||
const recharge = orderResult.recharge;
|
||||
if (recharge.status === 2 || recharge.status === 3) {
|
||||
uni.showToast({ title: `充值订单${recharge.status_name || '不可支付'},请重新下单`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (recharge.status === 1 && !hasPreparedPaymentData(orderResult)) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '检测到您有待支付的充值订单,是否前往我的钱包查看?',
|
||||
confirmText: '去查看',
|
||||
cancelText: '取消',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) uni.navigateTo({ url: '/pages/my-wallet/my-wallet' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasPreparedPaymentData(orderResult)) {
|
||||
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const packageName = packageNamesText.value;
|
||||
const rechargeAmount = orderResult.linked_package_info?.force_recharge_amount || recharge.amount;
|
||||
uni.showModal({
|
||||
title: '需要充值',
|
||||
content: `续费${packageName}需要先充值¥${formatMoney(rechargeAmount)},充值成功后将自动购买套餐`,
|
||||
confirmText: '去充值',
|
||||
cancelText: '取消',
|
||||
success: async ({ confirm }) => {
|
||||
if (confirm) await handlePreparedPayment(orderResult, null, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const confirmPay = async () => {
|
||||
if (submitting.value) return;
|
||||
if (operationMode.value === 'order-payment') {
|
||||
await confirmOrderPayment();
|
||||
return;
|
||||
}
|
||||
if (!packageIds.value.length) return;
|
||||
submitting.value = true;
|
||||
uni.showLoading({ title: '创建订单...', mask: true });
|
||||
|
||||
try {
|
||||
const orderResult = await orderApi.create(props.identifier, packageIds.value, paymentMethod.value);
|
||||
if (orderResult?.order_type === 'recharge' && orderResult.recharge) {
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
await handleRechargeOrder(orderResult);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = orderResult?.order?.order_id;
|
||||
if (!orderId) {
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
uni.showToast({ title: '订单创建失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '准备支付...', mask: true });
|
||||
// Prefer payment data returned by order creation. The fallback keeps
|
||||
// compatibility with older backends without a duplicate preparation
|
||||
// request when the new contract is available.
|
||||
const payResult = hasPreparedPaymentData(orderResult) ||
|
||||
(paymentMethod.value === 'wallet' && orderResult.order.payment_status === 2)
|
||||
? orderResult
|
||||
: await orderApi.pay(orderId, paymentMethod.value);
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
|
||||
if (paymentMethod.value === 'wallet') {
|
||||
await confirmOrderStatus(orderId);
|
||||
return;
|
||||
}
|
||||
await handlePreparedPayment(payResult, orderId);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
console.error('创建续费订单或支付失败', error);
|
||||
if (isUnknownPaymentResult(error)) {
|
||||
markPendingPaymentRefresh(props.paymentRefreshTarget);
|
||||
}
|
||||
uni.showToast({ title: getPaymentErrorMessage(error, '操作失败,请稍后重试'), icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrderPayment = async () => {
|
||||
if (!orderPaymentId.value) return;
|
||||
submitting.value = true;
|
||||
uni.showLoading({ title: '准备支付...', mask: true });
|
||||
|
||||
try {
|
||||
const payData = await orderApi.pay(orderPaymentId.value, paymentMethod.value);
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
|
||||
if (paymentMethod.value === 'wallet') {
|
||||
await confirmOrderStatus(orderPaymentId.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentMethod.value === 'wechat') {
|
||||
if (!isValidWechatPayConfig(payData?.pay_config)) {
|
||||
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await wechatH5Pay(payData.pay_config);
|
||||
await confirmOrderStatus(orderPaymentId.value);
|
||||
} catch (error) {
|
||||
handlePaymentError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidAlipayPaymentLink(payData?.payment_link)) {
|
||||
uni.showToast({ title: '支付链接获取失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
await openAlipayPayment(payData.payment_link, props.paymentRefreshTarget);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('订单支付失败', error);
|
||||
if (isUnknownPaymentResult(error)) {
|
||||
markPendingPaymentRefresh(props.paymentRefreshTarget);
|
||||
}
|
||||
uni.showToast({ title: getPaymentErrorMessage(error), icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const payOrderDirectly = async (orderId, method = 'wallet') => {
|
||||
if (!orderId || submitting.value) return;
|
||||
submitting.value = true;
|
||||
uni.showLoading({ title: '准备支付...', mask: true });
|
||||
|
||||
try {
|
||||
await orderApi.pay(orderId, method);
|
||||
uni.hideLoading();
|
||||
if (method === 'wallet') {
|
||||
await confirmOrderStatus(orderId);
|
||||
}
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('直接支付订单失败', error);
|
||||
if (isUnknownPaymentResult(error)) {
|
||||
markPendingPaymentRefresh(props.paymentRefreshTarget);
|
||||
}
|
||||
uni.showToast({ title: getPaymentErrorMessage(error), icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, openOrderPayment, payOrderDirectly });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.renewal-popup {
|
||||
width: 600rpx;
|
||||
max-width: calc(100vw - 80rpx);
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header,
|
||||
.popup-footer,
|
||||
.method-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
justify-content: space-between;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1rpx solid var(--border-light);
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 40rpx;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.package-summary {
|
||||
padding: 32rpx 30rpx 24rpx;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-name {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.summary-price {
|
||||
margin-top: 14rpx;
|
||||
color: var(--primary);
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.method-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border: 2rpx solid var(--border-light);
|
||||
border-radius: var(--radius-medium);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(85, 171, 92, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.method-left { gap: 20rpx; }
|
||||
|
||||
.method-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.method-badge-alipay { background: #1677ff; }
|
||||
.method-name { color: var(--text-primary); font-size: 28rpx; }
|
||||
.method-locked-label { color: var(--text-tertiary); font-size: 24rpx; white-space: nowrap; }
|
||||
|
||||
.method-radio {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border: 2rpx solid var(--gray-400);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&.checked {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
|
||||
&::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.method-empty {
|
||||
padding: 28rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
padding: 30rpx;
|
||||
gap: 20rpx;
|
||||
border-top: 1rpx solid var(--border-light);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
131
components/SilenceReminderPopup.vue
Normal file
131
components/SilenceReminderPopup.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<view class="silence-overlay" :class="{ 'silence-overlay-blocking': blocking }" @tap.stop @touchmove.stop.prevent>
|
||||
<view class="silence-dialog" :class="`silence-${reminder.kind}`" role="dialog" aria-modal="true" aria-label="套餐沉默期提醒">
|
||||
<button v-if="!blocking" class="silence-close" aria-label="关闭沉默期提醒" @tap="$emit('close')">×</button>
|
||||
<view class="silence-title">{{ title }}</view>
|
||||
<view v-if="reminder.packageName" class="silence-package">{{ reminder.packageName }}</view>
|
||||
<view class="silence-body">
|
||||
<template v-if="reminder.pendingOrderNo">
|
||||
套餐沉默期已于 <text class="silence-strong">{{ deadline }}</text> 过期,您有一笔未支付的延期订单({{ reminder.pendingOrderNo }}),请继续支付;不需要了可以作废后重新办理。
|
||||
</template>
|
||||
<template v-else-if="reminder.kind === 'expired'">
|
||||
套餐需在 <text class="silence-strong">{{ deadline }}</text> 前完成实名,现已过期。请先办理延期,延期后才能继续使用其他功能。
|
||||
</template>
|
||||
<template v-else>
|
||||
套餐需在 <text class="silence-strong">{{ deadline }}</text> 前完成实名激活,还剩
|
||||
<text class="silence-strong">{{ reminder.remainingDays }}</text> 天,过期后需办理延期。
|
||||
</template>
|
||||
</view>
|
||||
<button class="silence-confirm" @tap="$emit('action')">{{ actionText }}</button>
|
||||
<button v-if="!blocking" class="silence-later" @tap="$emit('close')">我知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { formatSilenceDate, isSilenceReminderBlocking } from '@/utils/silence.js';
|
||||
|
||||
const props = defineProps({ reminder: { type: Object, required: true } });
|
||||
defineEmits(['close', 'action']);
|
||||
const blocking = computed(() => isSilenceReminderBlocking(props.reminder));
|
||||
const deadline = computed(() => formatSilenceDate(props.reminder.silence?.silence_deadline_at));
|
||||
const title = computed(() => {
|
||||
if (props.reminder.pendingOrderNo) return '延期订单未支付';
|
||||
return props.reminder.kind === 'expired' ? '套餐沉默期已过期' : '请尽快完成实名';
|
||||
});
|
||||
const actionText = computed(() => {
|
||||
if (props.reminder.pendingOrderNo) return '去支付或作废';
|
||||
return props.reminder.kind === 'expired' ? '去延期' : '查看套餐';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.silence-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(20, 26, 36, 0.48);
|
||||
}
|
||||
|
||||
/* 强制延期弹窗盖在首页所有弹层(含 up-popup)之上 */
|
||||
.silence-overlay-blocking {
|
||||
z-index: 10200;
|
||||
background: rgba(20, 26, 36, 0.64);
|
||||
}
|
||||
|
||||
.silence-dialog {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
padding: 48rpx 36rpx 32rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 32rpx;
|
||||
text-align: center;
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.silence-close {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
right: 20rpx;
|
||||
padding: 0;
|
||||
font-size: 44rpx;
|
||||
line-height: 1;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.silence-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.silence-expired .silence-title {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.silence-package {
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.silence-body {
|
||||
margin: 28rpx 0 36rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.silence-strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.silence-confirm {
|
||||
width: 100%;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.silence-later {
|
||||
width: 100%;
|
||||
margin-top: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
&::after { border: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -39,27 +39,34 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { assetApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import {
|
||||
computed
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
identifier: { type: String, default: '' }
|
||||
assetInfo: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['packageLoaded']);
|
||||
// 和首页共用最新资产快照,不保留只在首次进入时加载的套餐缓存。
|
||||
const metrics = computed(() => props.assetInfo?.current_package_usage_id
|
||||
? getTrafficMetrics(props.assetInfo)
|
||||
: { used: 0, total: 0, remain: 0 });
|
||||
const usedMB = computed(() => metrics.value.used);
|
||||
const totalMB = computed(() => metrics.value.total);
|
||||
const remainMB = computed(() => metrics.value.remain);
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 流量数据
|
||||
const usedMB = ref(0);
|
||||
const totalMB = ref(0);
|
||||
const remainMB = ref(0);
|
||||
const toMB = (value) => Number(value) || 0;
|
||||
|
||||
// 格式化流量数据(MB 转 GB)
|
||||
const formatTraffic = (mb) => {
|
||||
if (!mb || mb === 0) {
|
||||
return { value: '0.00', unit: 'MB' };
|
||||
return {
|
||||
value: '0.00',
|
||||
unit: 'MB'
|
||||
};
|
||||
}
|
||||
|
||||
if (mb >= 1024) {
|
||||
@@ -89,57 +96,26 @@
|
||||
|
||||
// 使用百分比
|
||||
const usedFlowPercent = computed(() => {
|
||||
if (totalMB.value && usedMB.value) {
|
||||
if (totalMB.value > 0) {
|
||||
const percent = (usedMB.value / totalMB.value * 100);
|
||||
return Math.min(percent, 100).toFixed(2);
|
||||
}
|
||||
return '0.00';
|
||||
});
|
||||
|
||||
// 加载套餐流量数据
|
||||
const loadPackageData = async () => {
|
||||
const identifier = props.identifier || userStore.state.identifier;
|
||||
if (!identifier) return;
|
||||
const getTrafficMetrics = (data) => {
|
||||
const enableVirtualData = !!data.enable_virtual_data;
|
||||
const used = enableVirtualData ? toMB(data.virtual_used_mb) : toMB(data.real_used_mb);
|
||||
const total = toMB(data.real_total_mb);
|
||||
const remain = Math.max(total - used, 0);
|
||||
|
||||
try {
|
||||
const data = await assetApi.getPackageHistory(identifier, 1, 10, { status: 1 });
|
||||
|
||||
if (data.items && data.items.length > 0) {
|
||||
// 只有一个生效中的套餐
|
||||
const activePackage = data.items[0];
|
||||
|
||||
if (activePackage.enable_virtual_data) {
|
||||
// 启用虚流量
|
||||
usedMB.value = activePackage.virtual_used_mb || 0;
|
||||
totalMB.value = activePackage.virtual_limit_mb || 0;
|
||||
remainMB.value = activePackage.virtual_remain_mb || 0;
|
||||
} else {
|
||||
// 未启用虚流量,使用真流量
|
||||
usedMB.value = activePackage.data_usage_mb || 0;
|
||||
totalMB.value = activePackage.data_limit_mb || 0;
|
||||
remainMB.value = totalMB.value - usedMB.value;
|
||||
}
|
||||
|
||||
// 将套餐信息(包括到期时间)传递给父组件
|
||||
emit('packageLoaded', activePackage);
|
||||
} else {
|
||||
// 没有生效中的套餐
|
||||
usedMB.value = 0;
|
||||
totalMB.value = 0;
|
||||
remainMB.value = 0;
|
||||
emit('packageLoaded', null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载套餐流量数据失败', e);
|
||||
}
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remain
|
||||
};
|
||||
};
|
||||
|
||||
// 监听 identifier 变化
|
||||
watch(() => props.identifier || userStore.state.identifier, (newVal) => {
|
||||
if (newVal) {
|
||||
loadPackageData();
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -148,14 +124,23 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
.title { font-size: 35rpx; font-weight: 700; margin-right: 5rpx; }
|
||||
|
||||
.title {
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
margin-right: 5rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.traffic-stats {
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 20rpx;
|
||||
margin-top: 16rpx;
|
||||
.stat-item { flex: 1; }
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<view class="card user-info-card interactive">
|
||||
<view class="flex-row-g20">
|
||||
<view class="user-avatar">
|
||||
<image :src="avatarUrl" mode="aspectFill" alt="用户头像" />
|
||||
</view>
|
||||
<view class="user-details flex-col-g8">
|
||||
<view class="flex-row-g20">
|
||||
<view class="info-copy-row">
|
||||
<view class="title">{{ currentCardNo || '-' }}</view>
|
||||
<button class="copy-button" @tap.stop="copyText(currentCardNo, '号码')" aria-label="复制号码">复制</button>
|
||||
</view>
|
||||
<view v-if="!isDevice" class="info-copy-row">
|
||||
<view class="caption">ICCID:{{ deviceInfo.iccid || '-' }}</view>
|
||||
<button class="copy-button" @tap.stop="copyText(deviceInfo.iccid, 'ICCID')" aria-label="复制ICCID">复制</button>
|
||||
</view>
|
||||
<view class="caption">套餐名称:{{ deviceInfo.packageName || '-' }}</view>
|
||||
<view class="caption" :class="{ 'expiry-warning': isExpiring }">
|
||||
套餐到期时间:{{ deviceInfo.expireDate || '-' }}
|
||||
<text v-if="isExpiring && expiryDays !== null">(剩余 {{ expiryDays }} 天)</text>
|
||||
</view>
|
||||
<view class="caption" v-if="isDevice">IMEI:{{ deviceInfo.imei || '-' }}</view>
|
||||
<view class="caption" v-else>手机号:{{ deviceInfo.bound_phone || '-' }}</view>
|
||||
</view>
|
||||
<view v-if="isDevice" class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
|
||||
{{ onlineStatus }}
|
||||
@@ -18,43 +23,63 @@
|
||||
{{ networkStatus == 1 ? '正常' : '停机' }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- <up-button v-if="hasPackageName" class="renew-button" type="primary" size="small"
|
||||
@tap.stop="emit('renew')">立即续费</up-button> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const emit = defineEmits(['renew']);
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
currentCardNo: { type: String, default: '' },
|
||||
deviceInfo: { type: Object, default: () => ({}) },
|
||||
onlineStatus: { type: String, default: '离线' },
|
||||
networkStatus: { type: String, default: '离线' },
|
||||
isDevice: { type: Boolean, default: true }
|
||||
networkStatus: { type: [String, Number], default: '离线' },
|
||||
isDevice: { type: Boolean, default: true },
|
||||
isExpiring: { type: Boolean, default: false },
|
||||
expiryDays: { type: [Number, String], default: null }
|
||||
});
|
||||
|
||||
// 默认头像
|
||||
const defaultAvatar = 'https://img1.baidu.com/it/u=2462918877,1866131262&fm=253&fmt=auto&app=138&f=JPEG?w=506&h=500';
|
||||
|
||||
// 用户头像,优先使用 store 中的头像,否则使用默认头像
|
||||
const avatarUrl = computed(() => {
|
||||
return userStore.state.userInfo.avatar || defaultAvatar;
|
||||
const hasPackageName = computed(() => {
|
||||
const packageName = String(props.deviceInfo?.packageName || '').trim();
|
||||
return Boolean(packageName && packageName !== '-');
|
||||
});
|
||||
|
||||
const copyText = (value, label) => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || text === '-') return;
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: `${label}已复制`, icon: 'success' })
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-info-card {
|
||||
color: var(--text-primary);
|
||||
.user-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: var(--radius-medium);
|
||||
overflow: hidden;
|
||||
border: 2rpx solid var(--border-light);
|
||||
image { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.expiry-warning { color: var(--danger); }
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
.user-details { flex: 1; }
|
||||
|
||||
.info-copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.renew-button {
|
||||
margin: 24rpx 0 0;
|
||||
width: 180rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<view class="card-header flex-row-sb">
|
||||
<view class="title">WiFi配置</view>
|
||||
<view class="btn-group">
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('modify')">修改</button>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="copyConfig">复制配置</button>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('modify')">修改配置</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="wifi-info flex-col-g16 mt-30">
|
||||
@@ -12,25 +13,27 @@
|
||||
<view class="caption">网络名称</view>
|
||||
<view class="subtitle">{{ deviceInfo.ssidName }}</view>
|
||||
</view>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('copy', deviceInfo.ssidName)">复制</button>
|
||||
</view>
|
||||
<view class="wifi-item flex-row-sb">
|
||||
<view class="wifi-details flex-col-g8">
|
||||
<view class="caption">连接密码</view>
|
||||
<view class="subtitle">{{ deviceInfo.ssidPwd }}</view>
|
||||
</view>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('copy', deviceInfo.ssidPwd)">复制</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
deviceInfo: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
defineEmits(['modify', 'copy']);
|
||||
const emit = defineEmits(['modify', 'copy-config']);
|
||||
|
||||
const copyConfig = () => {
|
||||
emit('copy-config', `网络名称: ${props.deviceInfo.ssidName || ''} 连接密码: ${props.deviceInfo.ssidPwd || ''}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -44,4 +47,8 @@
|
||||
.wifi-details { flex: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group{
|
||||
padding-right: var(--space-md);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,9 @@ services:
|
||||
- '3003:80'
|
||||
networks:
|
||||
- device-voice-network
|
||||
tmpfs:
|
||||
- /run:rw
|
||||
- /tmp:rw
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/health']
|
||||
interval: 30s
|
||||
|
||||
@@ -18,6 +18,8 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
alias /usr/share/nginx/html/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
|
||||
335
docs/10月份修改/第1批-8-9月迭代前端对接说明.md
Normal file
335
docs/10月份修改/第1批-8-9月迭代前端对接说明.md
Normal 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 导出由后端新增列。
|
||||
1754
docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md
Normal file
1754
docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md
Normal file
File diff suppressed because it is too large
Load Diff
111
docs/API.md
111
docs/API.md
@@ -1,4 +1,4 @@
|
||||
# BaseUrl: https://cmp-api.boss160.cn
|
||||
# BaseUrl: https://cmp-api.xm-iot.cn
|
||||
|
||||
错误响应示例:
|
||||
{
|
||||
@@ -328,6 +328,13 @@ url: /api/c/v1/asset/info
|
||||
URL:
|
||||
GET /api/c/v1/asset/info
|
||||
|
||||
更新说明(2026-05-08):
|
||||
* 本月流量展示以该接口返回字段为准
|
||||
* 总流量:`real_total_mb`
|
||||
* 已使用:`enable_virtual_data=true` 时取 `virtual_used_mb`,否则取 `real_used_mb`
|
||||
* 剩余:`real_total_mb - 已使用`
|
||||
* 进度条:`已使用 / real_total_mb`
|
||||
|
||||
Query 参数:
|
||||
identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
||||
|
||||
@@ -369,11 +376,111 @@ data:
|
||||
- wallet_balance:钱包余额(分)
|
||||
|
||||
|
||||
## 3.2 资产套餐历史
|
||||
## 3.2 资产套餐历史(当前契约)
|
||||
|
||||
URL:
|
||||
GET /api/c/v1/asset/package-history
|
||||
|
||||
更新说明(2026-09-08):
|
||||
* 套餐历史已改为主套餐—加油包关系组。`items` 只包含顶层关系组;关联加油包位于所属主项的 `children` 中,子项绝不会跨页返回。
|
||||
* `total` 为筛选后的顶层关系组数量,分页壳使用 `page`、`size`、`total`;其中 `size` 表示每页顶层关系组数量。
|
||||
* 传入 `status` 和 `package_type` 时,两项必须由同一条使用记录联合命中;主项或任一子项命中时,接口均返回完整关系组,客户端不得对组内节点二次过滤。
|
||||
* 加油包通过 `master_usage_id` 标识关联主套餐使用记录。关联主套餐物理缺失时,节点以独立顶层项返回并设置 `relationship_status: "master_missing"`;关联主套餐存在但读取或展示失败时,接口按统一错误响应返回失败。
|
||||
|
||||
Query 参数:
|
||||
- identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN),长度 1–50
|
||||
- package_type(string,可选)- 套餐类型(formal:正式套餐;addon:加油包)
|
||||
- status(integer,可选)- 套餐状态(0:待生效;1:生效中;2:已用完;3:已过期;4:已失效)
|
||||
- page(integer,必填)- 页码,最小为 1
|
||||
- page_size(integer,必填)- 每页顶层关系组数量,范围 1–100
|
||||
|
||||
成功响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"timestamp": "2026-09-08T00:00:00Z",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"activated_at": "2026-09-01T00:00:00Z",
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"expand_by_default": false,
|
||||
"master_usage_id": 5001,
|
||||
"package_id": 1002,
|
||||
"package_name": "5GB加油包",
|
||||
"package_type": "addon",
|
||||
"package_usage_id": 5002,
|
||||
"status": 1,
|
||||
"status_name": "生效中"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-09-01T00:00:00Z",
|
||||
"enable_virtual_data": false,
|
||||
"expand_by_default": true,
|
||||
"expires_at": "2026-09-30T23:59:59Z",
|
||||
"master_usage_id": null,
|
||||
"order_id": 0,
|
||||
"package_id": 1001,
|
||||
"package_name": "10GB月套餐",
|
||||
"package_type": "formal",
|
||||
"package_usage_id": 5001,
|
||||
"priority": 1,
|
||||
"real_total_mb": 10240,
|
||||
"real_used_mb": 2048,
|
||||
"reduction_pct": 0,
|
||||
"status": 1,
|
||||
"status_name": "生效中",
|
||||
"usage_type": "single_card",
|
||||
"virtual_total_mb": 10240,
|
||||
"virtual_used_mb": 2048
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"size": 10,
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data.items[]` 为递归节点,所有节点均可包含以下字段:
|
||||
- activated_at(date-time,可空):激活时间
|
||||
- children(array):关联加油包或下级关联节点
|
||||
- created_at(date-time):购买创建时间
|
||||
- enable_virtual_data(boolean):是否启用虚流量
|
||||
- expand_by_default(boolean):是否默认展开 `children`
|
||||
- expires_at(date-time,可空):到期时间
|
||||
- master_usage_id(integer,可空):关联主套餐使用记录 ID;普通主项为 `null`
|
||||
- order_id(integer):历史兼容字段,始终输出零值 `0`,不填充真实订单 ID
|
||||
- package_id、package_name、package_type、package_usage_id:套餐及使用记录标识;`package_type` 为 `formal` 或 `addon`
|
||||
- priority:优先级
|
||||
- real_total_mb、real_used_mb:真实总量和真实已用量(MB)
|
||||
- virtual_total_mb、virtual_used_mb:业务停机阈值和展示已用量(MB)
|
||||
- reduction_pct:展示增幅比例
|
||||
- status、status_name:套餐状态及名称
|
||||
- usage_type:使用类型(single_card/device)
|
||||
- relationship_status、relationship_status_name:关系异常状态及名称;仅在主套餐物理缺失时返回 `master_missing`
|
||||
|
||||
错误响应:
|
||||
- HTTP 400:请求参数错误
|
||||
- HTTP 401:未认证或认证已过期
|
||||
- HTTP 403:无权访问
|
||||
- HTTP 500:服务器内部错误,包括关联主套餐存在但读取或展示失败
|
||||
|
||||
以上失败场景均使用统一 `ErrorResponse`:`code`、`msg`、`timestamp`,可选 `data`。H5 通过共享请求层展示后端返回的 `msg`;401 同时清除登录状态并跳转登录页。
|
||||
|
||||
## 3.2 资产套餐历史(旧版,已废弃)
|
||||
|
||||
URL:
|
||||
GET /api/c/v1/asset/package-history
|
||||
|
||||
更新说明(2026-05-08):
|
||||
* 涉及“本月流量”展示时,以 `/api/c/v1/asset/info` 的规则为准
|
||||
* 旧说明中“启用虚流量时总流量/剩余切换为虚流量”的规则已废弃
|
||||
* 当前规则:总流量固定取 `real_total_mb`,已使用按 `enable_virtual_data` 在 `virtual_used_mb` / `real_used_mb` 间切换,剩余和进度条按该结果计算
|
||||
|
||||
Query 参数:
|
||||
- identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
||||
- page(integer,必填)- 页码
|
||||
|
||||
286
docs/产品迭代7月份/七月迭代H5_C端改动说明.md
Normal file
286
docs/产品迭代7月份/七月迭代H5_C端改动说明.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 七月迭代 H5/C 端改动说明
|
||||
|
||||
本文根据《七月迭代实现与接口对接说明》整理,重点说明本期 H5/C 端需要调整的页面、接口和预期效果。
|
||||
|
||||
## 一、改动总览
|
||||
|
||||
本期 H5/C 端主要涉及以下功能:
|
||||
|
||||
1. 店铺 C 端登录限制。
|
||||
2. 实名认证流程和实名状态展示。
|
||||
3. 支付方式展示与支付提交。
|
||||
4. 下架套餐老客户续费。
|
||||
5. 预计套餐最终到期时间展示。
|
||||
6. 套餐临期提醒。
|
||||
7. 换货通知提醒。
|
||||
8. C 端订单字段展示调整。
|
||||
|
||||
## 二、具体改动说明
|
||||
|
||||
### 1. 店铺 C 端登录限制(#41)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
资产验证后,根据接口返回结果判断是否允许登录。如果接口返回店铺已禁止 C 端登录,应直接展示后端错误提示,不继续获取或保存资产 Token。
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
POST /api/c/v1/auth/verify-asset
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 被限制的店铺无法新登录 H5/C 端。
|
||||
- 前端展示后端返回的业务错误信息。
|
||||
- 已经签发的 Token 不会被强制吊销。
|
||||
|
||||
### 2. 实名认证流程(#62)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
资产初始化时只使用后端返回的实名策略和实名状态,不要根据卡、设备或前端本地规则自行推断。
|
||||
|
||||
重点使用以下字段:
|
||||
|
||||
- `effective_realname_policy`
|
||||
- `realname_required`
|
||||
- `real_name_status`
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info?identifier=...
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- `none`:不需要实名。
|
||||
- `before_order`:下单前要求实名。
|
||||
- `after_order`:下单后要求实名。
|
||||
- 卡和设备存在策略冲突时,以设备最终生效策略为准。
|
||||
- 设备只要有一张有效绑定卡已实名,即视为设备已实名。
|
||||
|
||||
### 3. 支付方式展示和提交(#48)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
支付按钮和支付方式列表必须使用后端返回的 `allowed_payment_methods`,不能在前端写死卡、设备的微信、支付宝或钱包规则。
|
||||
|
||||
下单或充值时必须将用户选择的 `payment_method` 传给后端;微信支付场景按接口要求传递 `app_type`。
|
||||
|
||||
#### 接口
|
||||
|
||||
资产初始化:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info
|
||||
```
|
||||
|
||||
充值前校验:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/wallet/recharge-check
|
||||
```
|
||||
|
||||
创建订单:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
钱包充值:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/wallet/recharge
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 不同资产类型显示正确的支付方式。
|
||||
- 强充和普通充值遵循后端允许的支付配置。
|
||||
- 前端无法绕过后端支付限制。
|
||||
- 支付方式变化后,前端无需重新发布即可按接口结果生效。
|
||||
|
||||
### 4. 下架套餐老客户续费(#40)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
普通套餐列表中不展示下架套餐,但当前正在使用下架套餐的老客户仍可通过资产信息或历史订单获取套餐 ID,并继续调用现有创建订单接口。
|
||||
|
||||
创建订单时传递:
|
||||
|
||||
- 选中的套餐 ID;
|
||||
- 资产 `identifier`;
|
||||
- 当前允许的 `payment_method`。
|
||||
|
||||
#### 接口
|
||||
|
||||
获取当前资产套餐:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info
|
||||
```
|
||||
|
||||
获取历史订单:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/orders
|
||||
GET /api/c/v1/orders/:id
|
||||
```
|
||||
|
||||
创建续费订单:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
相关返回字段:
|
||||
|
||||
- 资产信息中的 `current_package_id`;
|
||||
- 历史订单中的 `package_ids`。
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 老客户可以继续续费已下架套餐。
|
||||
- 不新增专用续费接口,仍复用普通创建订单接口。
|
||||
- 新客户和代理代购仍不能购买下架套餐。
|
||||
- 历史订单数据不被修改。
|
||||
|
||||
### 5. 预计套餐最终到期时间(#46)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
资产详情、资产列表等页面展示后端返回的预计最终到期时间,不要只展示当前套餐的到期时间。
|
||||
|
||||
重点使用:
|
||||
|
||||
- `estimated_final_expires_at`
|
||||
- `is_expiring`
|
||||
- 剩余天数及临期等级字段(如接口返回)
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 用户看到资产综合计算后的最终到期时间。
|
||||
- 临期资产可以按后端返回的临期字段进行高亮。
|
||||
- 避免因只显示当前套餐到期时间导致到期日期不准确。
|
||||
|
||||
### 6. 套餐临期提醒(#33)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
接入 C 端现有站内通知能力,读取未读通知并在合适时机展示弹窗或提醒入口。
|
||||
|
||||
#### 接口
|
||||
|
||||
获取未读数量:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/notifications/unread-count
|
||||
```
|
||||
|
||||
获取通知列表:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/notifications
|
||||
```
|
||||
|
||||
标记已读:
|
||||
|
||||
```http
|
||||
PUT /api/c/v1/notifications/:id/read
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 在套餐剩余 15 天、7 天、3 天时触发提醒。
|
||||
- 前端可按后端返回的临期等级进行展示。
|
||||
- 0~3 天的临期提醒优先级最高。
|
||||
- 通知通过 C 端站内消息展示,不新增企微业务员提醒。
|
||||
|
||||
### 7. 换货通知提醒(#188)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
继续使用现有 C 端通知接口,展示换货相关的未读通知,并支持点击后标记已读。
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/notifications/unread-count
|
||||
GET /api/c/v1/notifications
|
||||
PUT /api/c/v1/notifications/:id/read
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 换货创建后,用户可以在 H5/C 端收到站内通知。
|
||||
- 首页或通知入口能够展示未读数量。
|
||||
- 用户查看后可以正常标记已读。
|
||||
- 不新增营销投放、ERP 或其他业务单据。
|
||||
|
||||
### 8. C 端订单字段展示(#181)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
订单列表和订单详情使用后端返回的订单角色及资产标识字段。
|
||||
|
||||
重点字段:
|
||||
|
||||
- `purchase_role`
|
||||
- `asset_identifier`
|
||||
|
||||
设备资产标识的显示规则:
|
||||
|
||||
1. 优先显示 `VirtualNo`;
|
||||
2. `VirtualNo` 为空时显示 `IMEI`;
|
||||
3. 不使用 SN 冒充订单设备标识;
|
||||
4. 历史空数据不需要前端伪造。
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/orders
|
||||
GET /api/c/v1/orders/:id
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 订单中的购买角色显示准确。
|
||||
- 卡展示正确的 ICCID 或资产标识。
|
||||
- 设备展示 VirtualNo,缺失时使用 IMEI。
|
||||
- 订单资产标识与退款、换货等业务中的固化快照保持一致。
|
||||
|
||||
## 三、H5/C 端不需要改动的内容
|
||||
|
||||
以下事项本期明确不需要 H5/C 端新增功能或接口:
|
||||
|
||||
- #84 H5 首页隐藏设备下 ICCID:本期确认不做。
|
||||
- #73 行业卡未实名复机:保持现有逻辑。
|
||||
- #94 状态同步和运营商回调:继续读取现有资产状态字段,无新增 H5/C 端调用。
|
||||
- 企业微信审批回调:H5/C 端不调用。
|
||||
- 原路退款、聚水潭、跨品类换货、分销码/佣金提现:本期不做。
|
||||
|
||||
## 四、H5/C 端联调注意事项
|
||||
|
||||
- 金额接口字段默认单位为“分”,页面展示时转换为“元”,提交时仍传整数分。
|
||||
- `effective_realname_policy` 和 `allowed_payment_methods` 必须以后端返回值为准。
|
||||
- 企微审批业务只读展示 `approval_provider`、`approval_status`、`approval_status_name`,不能再显示旧的人工通过、驳回或线下充值确认按钮。
|
||||
- 企微回调接口由企微服务器调用,H5/C 端不调用:
|
||||
|
||||
```http
|
||||
GET/POST /api/callback/wecom/approval/:application_id
|
||||
```
|
||||
|
||||
- 前端不要根据接口名称自行假设存在“下架套餐续费接口”,续费仍调用:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
284
docs/产品迭代7月份/七月迭代实现与接口对接说明(1).md
Normal file
284
docs/产品迭代7月份/七月迭代实现与接口对接说明(1).md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 七月迭代实现与接口对接说明
|
||||
|
||||
> 面向:产品、前端、测试和联调人员
|
||||
> 范围:`deliver-july-iteration-confirmed-scope` 及本期确认“后端已完成,只需前端联调”的需求
|
||||
> 接口细节:以 [`docs/admin-openapi.yaml`](../admin-openapi.yaml) 为准,本文只说明关键调用和字段变化。
|
||||
|
||||
## 一、先看这几个关键结论
|
||||
|
||||
1. **设备没有限速接口**:限速只允许对 IoT 卡 ICCID 操作,固定档位 `-1~8`,不通过设备绑定卡间接限速。
|
||||
2. **企微模板在企微后台创建**:系统只配置应用、默认发起人、账号 userid 绑定、`template_id` 和控件映射,不在本系统设计审批节点。
|
||||
3. **代理发起审批走默认发起人**:企微单据使用配置的默认成员发起,但退款、充值等本地业务单仍记录真实代理提交人。
|
||||
4. **下架套餐续费不新增专用接口**:历史订单和资产信息返回稳定套餐 ID,前端仍调用现有 C 端创建订单接口生成一张新订单。
|
||||
5. **实名流程由后端返回值决定**:前端使用 `effective_realname_policy`,不能按卡或设备自行推断;设备有任意一张有效绑定卡已实名即视为已实名。
|
||||
6. **支付按钮由后端返回值决定**:前端使用 `allowed_payment_methods`,不要自行写死卡/设备的微信、支付宝或钱包规则。
|
||||
7. **设备批量分配只复用导入任务外壳**:复用任务表、队列、进度和结果页;不会进入原 Excel 创建设备逻辑。
|
||||
8. **系列套餐批量授权后端原本就支持多选**:前端把多选套餐组装为 `packages[]` 调现有接口即可,不需要新后端接口。
|
||||
|
||||
## 二、本期需求怎么实现
|
||||
|
||||
### 2.1 本期新增或修改后端的需求
|
||||
|
||||
| 需求 | 实现方式 | 关键口径检查 |
|
||||
| --- | --- | --- |
|
||||
| #189 换货后退款套餐未失效 | 退款处理不再只按旧资产查套餐,而是按原订单和换货迁移关系定位新资产上的对应套餐权益 | 只失效该退款订单产生的权益,不影响其他订单套餐;无前端改动 |
|
||||
| #188 换货 C 端提醒 | 创建物流换货单时写可靠通知事件,继续走现有 C 端站内通知和未读弹窗 | 不做营销投放、ERP、自动创建其他单据 |
|
||||
| #182/#44 提交人和审批展示 | 退款、代理充值、换货列表/详情批量解析提交人 ID 和名称;企微详情已具备将审批节点 userid 批量映射系统账号的内部能力 | 当前退款/充值业务 DTO 只稳定返回提交人和审批状态,尚未直接返回审批节点人员列表;如页面必须展示具体审批人,仍需把已有投影接入业务查询 |
|
||||
| #181 订单渠道和资产标识 | C 端新订单保存正确 `purchase_role`;卡返回 ICCID,设备优先 VirtualNo、为空时返回 IMEI | 不用 SN 冒充订单设备标识;历史空数据不伪造 |
|
||||
| #41 店铺 C 端登录限制 | 店铺增加 `client_login_disabled`;C 端验证资产后、签发短期令牌前检查所属店铺 | 只阻止新登录,不吊销已有 Token;平台库存保持原行为 |
|
||||
| #53 卡/设备实名筛选 | 卡按自身实名状态过滤;设备通过有效绑定卡 `EXISTS` 实时判断 | 设备任意一张有效绑定卡已实名即为已实名;未建投影或 Worker |
|
||||
| #57 退款中禁止换货 | 在现有换货创建入口前检查资产未终结退款 | 拒绝文案为“该资产存在退款申请”;不依赖企微实时接口 |
|
||||
| #62 三种实名顺序 | 保留 `none/before_order/after_order`,补齐卡和设备最多 500 条批量修改,C 端返回生效策略 | 批量全成全败;设备和下卡冲突时以设备策略为准 |
|
||||
| #97 主钱包低余额预警 | 消费主钱包扣款事实,余额首次从不少于 100 元跌破 100 元时通知店铺业务员 | 阈值以下不重复;恢复后再次跌破可再次提醒;无业务员不猜接收人 |
|
||||
| #33 套餐临期提醒 | 每日计算 15/7/3 天节点,提供后台/代理临期列表及数量,并向个人客户发送站内通知 | 高亮为 8~15、4~7、0~3 天;0~3 天优先;不发企微业务员提醒 |
|
||||
| #34 员工线下代充值 | 原充值创建入口在 `offline` 场景创建通用审批实例和企微提交事件;通过后幂等入主钱包 | 在线扫码充值后置;真实提交人、金额和凭证明文业务快照保留 |
|
||||
| #35 退款企微审批 | 原退款申请关联唯一通用审批实例,企微标准终态驱动现有退款处理 | 不做原路退款;重复回调/轮询不会重复退款或失效套餐 |
|
||||
| #37 企业微信审批 | 完成应用连接、通讯录同步、账号绑定、默认发起人、模板映射、提交、回调、详情查询和轮询恢复 | 模板/审批人规则由企微维护;超时结果未知不盲目重提 |
|
||||
| #36 批量订购套餐 | 单列 CSV,整批选择一个套餐和支付方式,走独立异步任务,逐行复用现有订单和钱包规则 | 不选择代理,不在 CSV 中逐行指定套餐;部分失败不影响其他行 |
|
||||
| #40 下架套餐老客户续费 | 普通可购列表排除下架套餐;当前使用者可用历史订单或当前资产返回的套餐 ID 调现有下单接口创建新订单 | 无专用续费接口;新客户和代理代购仍拒绝;历史订单不修改 |
|
||||
| #42 六类导出 | 在现有导出任务框架新增 IoT 卡、套餐、钱包流水、代理充值、退款、换货 datasource | 按现有数据权限和字段来源导出,不建设新字段权限平台 |
|
||||
| #47 卡固定档位限速 | 后台选择固定档位,按 ICCID 调用 Gateway,并记录操作审计和 Integration Log | 仅 IoT 卡;设备无接口;超时返回结果未知并人工核对 |
|
||||
| #48 按资产类型配置支付方式 | `system_config` 分别保存卡、设备允许的 `wallet/wechat/alipay` 集合;C 端返回业务场景交集,订单端再次校验 | 至少保留一种;强充和普通充值剔除钱包;前端不可绕过 |
|
||||
| #49 设备批量分配 | 扩展现有设备导入任务的 `operation_type`,新增分配代理和设置套餐系列两个 CSV 分支 | CSV 单列 VirtualNo/IMEI/SN;复用现有权限、分配、系列绑定和幂等规则 |
|
||||
|
||||
### 2.2 后端此前已经完成,主要由前端正确调用或展示
|
||||
|
||||
| 需求 | 后端现状 | 前端要做什么 |
|
||||
| --- | --- | --- |
|
||||
| #45 换货新旧资产展示/搜索 | 换货列表已分别返回新旧资产,并支持两个独立搜索参数 | 分别提供“旧资产”和“新资产”搜索框,不要继续共用一个字段 |
|
||||
| #46 预计套餐到期 | 资产详情、C 端资产信息和相关列表已返回预计最终到期字段 | 展示 `estimated_final_expires_at`;按 `is_expiring` 或剩余天数高亮,不要只显示当前套餐到期时间 |
|
||||
| #55 套餐分配生效条件 | 套餐和分配接口已支持默认值、覆盖值和最终生效值 | 创建/编辑套餐传 `expiry_base`;分配时传 `expiry_base_override`,展示 `effective_expiry_base` |
|
||||
| #60 店铺联系电话搜索 | 店铺列表已支持 11 位联系电话精确查询 | 将输入值作为 `contact_phone` 查询参数传给店铺列表接口 |
|
||||
| #86 资产换货标识和跳转 | 资产解析接口已返回 `exchange_trace.previous_asset/next_asset` 和 `can_view` | 仅 `can_view=true` 且存在资产 ID 时允许跳转;该需求前端已对接可保持现状 |
|
||||
| #38 代理信用额度 | 角色默认额度、店铺实际额度、资金概况和负可用余额均已有接口 | 使用分单位字段;更新时携带钱包 `version`;余额为负数时正常展示 |
|
||||
| #94 状态同步和运营商回调 | 后端回调、定时触发和原轮询链路已装配 | 通常无前端新调用;状态页面继续读取现有资产状态字段 |
|
||||
| #96 店铺业务员 | 店铺创建/更新、候选人、列表/详情和筛选都已支持业务员 | 创建/编辑店铺选择 `business_owner_account_id`;列表可按该 ID 筛选并展示名称 |
|
||||
| #98 换货新资产继承旧店铺 | 换货完成时后端自动继承旧资产店铺归属 | 前端继续调用原换货完成接口,不新增分配步骤 |
|
||||
| #43 系列套餐批量授权 | 创建授权和管理套餐接口均支持 `packages[]`,单次 1~100 项 | 页面实现套餐多选,一次提交整个数组;删除项使用 `remove=true` |
|
||||
|
||||
> 注意:#44 的“提交人”已经可以直接对接;“审批人”目前不是退款、充值列表的稳定返回字段。后端已经能从企微详情快照解析并映射 userid,但当前前端不能把 `processor_id` 当作完整企微审批人列表。
|
||||
|
||||
### 2.3 本期明确不由后端处理
|
||||
|
||||
- #84 H5 首页隐藏设备下 ICCID:纯前端显示调整,但本期范围确认标记为“不做”。
|
||||
- #63 授权列表滚动条和字段顺序:纯前端页面调整,需求已关闭。
|
||||
- #73 行业卡未实名复机:后端保持原有行业卡放行逻辑,不改接口、不改前端。
|
||||
- #99 原路退款、#52 聚水潭、#51 跨品类换货、#39 分销码/佣金提现:本期不做。
|
||||
- #168、#90、#75、#64:已关闭,本期不重新修改。
|
||||
|
||||
## 三、需求与接口对接表
|
||||
|
||||
### 3.1 资产、店铺、订单和换货
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #41 登录限制 | `PUT /api/admin/shops/:id`;`GET /api/admin/shops`;`GET /api/admin/shops/:id` | 更新请求增加可选 `client_login_disabled`;列表/详情返回同名布尔值。C 端仍调 `POST /api/c/v1/auth/verify-asset`,受限时直接展示后端错误,不会返回资产令牌 |
|
||||
| #53 实名筛选 | `GET /api/admin/iot-cards/standalone`;`GET /api/admin/devices` | 查询参数增加 `real_name_status=0|1`;响应已有 `real_name_status`、`real_name_status_name` |
|
||||
| #62 实名顺序 | `PATCH /api/admin/assets/:identifier/realname-mode`;`POST /api/admin/iot-cards/batch-update-realname-policy`;`POST /api/admin/devices/batch-update-realname-policy` | 单条传 `realname_policy`;批量传 `asset_ids[] + realname_policy`,最多 500 条 |
|
||||
| #62/#48 C 端初始化 | `GET /api/c/v1/asset/info?identifier=...` | 使用 `effective_realname_policy`、`realname_required`、`real_name_status`、`allowed_payment_methods`;前端不要自行覆盖 |
|
||||
| #45 换货搜索 | `GET /api/admin/exchanges` | 使用 `old_asset_keyword`、`new_asset_keyword` 两个独立参数,可同时传并按 AND 组合 |
|
||||
| #57/#98 换货 | `POST /api/admin/exchanges`;`POST /api/admin/exchanges/:id/complete` | 请求结构不变;存在退款时创建接口返回业务错误;完成后店铺归属由后端继承 |
|
||||
| #188 换货提醒 | `GET /api/c/v1/notifications/unread-count`;`GET /api/c/v1/notifications`;`PUT /api/c/v1/notifications/:id/read` | 无新增通知接口;前端继续使用现有未读数、列表和已读接口弹窗展示 |
|
||||
| #181 订单字段 | `GET /api/admin/orders`;`GET /api/admin/orders/:id`;`GET /api/c/v1/orders`;`GET /api/c/v1/orders/:id` | 返回正确 `purchase_role`、`asset_identifier`;设备标识为 VirtualNo 优先、IMEI 兜底 |
|
||||
| #40 下架套餐续费 | `GET /api/c/v1/asset/info`;`GET /api/c/v1/orders`;`GET /api/c/v1/orders/:id`;`POST /api/c/v1/orders/create` | 资产信息返回 `current_package_id`,历史订单返回 `package_ids`;前端把选中的套餐 ID、资产 `identifier` 和当前允许的 `payment_method` 传给原创建订单接口 |
|
||||
| #46/#86 资产展示 | `GET /api/admin/assets/resolve/:identifier`;`GET /api/c/v1/asset/info` | 返回预计最终到期字段;后台解析额外返回 `exchange_trace`,跳转前检查 `can_view` |
|
||||
|
||||
### 3.2 店铺业务员、信用和套餐授权
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #60 联系电话搜索 | `GET /api/admin/shops?contact_phone=11位号码` | 精确查询;可与店铺名称、编号等条件组合 |
|
||||
| #96 店铺业务员 | `GET /api/admin/shops/business-owner-candidates`;`POST /api/admin/shops`;`PUT /api/admin/shops/:id`;`GET /api/admin/shops` | 创建/更新传 `business_owner_account_id`;列表可用同名参数筛选,响应展示账号 ID、名称和可用状态 |
|
||||
| #38 信用额度 | `PUT /api/admin/roles/:id/default-credit`;`PUT /api/admin/shops/:id/credit-limit`;`GET /api/admin/shops/fund-summary` | 角色接口配置新建代理默认值;店铺接口传 `credit_enabled + credit_limit + version`;金额单位均为分 |
|
||||
| #55 套餐默认生效条件 | `POST /api/admin/packages`;`PUT /api/admin/packages/:id`;`GET /api/admin/packages/:id` | 请求使用 `expiry_base=from_activation|from_purchase`;响应展示默认生效条件名称 |
|
||||
| #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations`;`PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override`;`null` 表示跟随套餐默认值 |
|
||||
| #43 系列套餐多选 | `POST /api/admin/shop-series-grants`;`PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1~100 项数组,每项包含 `package_id`、`cost_price`,删除时传 `remove=true` |
|
||||
|
||||
#### 3.2.1 #43 套餐价格展示和已授权/未授权区分
|
||||
|
||||
#43 不需要新增后端接口,前端按下面两个现有接口组合数据:
|
||||
|
||||
1. 调用 `GET /api/admin/packages?series_id={series_id}&page=1&page_size=100` 分页取得该系列全部套餐。列表项直接使用:
|
||||
- `suggested_retail_price`:建议售价,单位分;未配置时为空。
|
||||
- `cost_price`:公司成本价,单位分。
|
||||
2. 编辑已有授权时,调用 `GET /api/admin/shop-series-grants/{grant_id}`,注意路径参数是授权记录 ID,不是系列 ID。
|
||||
3. 将授权详情的 `packages[].package_id` 组成已授权套餐 ID 集合。
|
||||
4. 套餐列表项的 `id` 在该集合中显示“已授权”,否则显示“未授权”。授权详情 `packages[].cost_price` 是该次店铺授权成本价,不要拿它替代套餐列表的公司成本价。
|
||||
5. 用户提交多选结果时,创建授权调用 `POST /api/admin/shop-series-grants`;编辑授权调用 `PUT /api/admin/shop-series-grants/{grant_id}/packages`。新增/调价项传 `package_id + cost_price`,删除项传 `package_id + remove=true`。
|
||||
|
||||
套餐接口是分页接口;一个系列超过 100 个套餐时,前端必须继续请求后续页,再完成已授权集合标记。
|
||||
|
||||
### 3.3 审批、退款和员工线下代充值
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #181/#182/#44 退款 | `GET /api/admin/refunds`;`GET /api/admin/refunds/:id` | 新增/补齐 `asset_identifier`、`submitter_id`、`submitter_name`、`approval_provider`、`approval_status`、`approval_status_name`;资产标识是退款创建时固化的快照,卡为 ICCID,设备按 VirtualNo 优先、IMEI 兜底;历史空快照不做兼容 |
|
||||
| #182/#44 充值 | `GET /api/admin/agent-recharges`;`GET /api/admin/agent-recharges/:id` | 同上;线下代充值的审批状态只读展示 |
|
||||
| #44 换货 | `GET /api/admin/exchanges`;`GET /api/admin/exchanges/:id` | 新增/补齐 `submitter_id`、`submitter_name` |
|
||||
| #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、1~5 个 `payment_voucher_key` 和备注,创建后等待企微审批 |
|
||||
| #35 退款申请 | `POST /api/admin/refunds` | 仍用原入口;请求结构保持订单、实收金额、申请金额、凭证、原因等业务字段,创建后等待企微审批 |
|
||||
| #34/#35 旧按钮 | `POST /api/admin/agent-recharges/:id/offline-pay`;`POST /api/admin/refunds/:id/approve|reject` | 仅兼容存量旧审批记录;企微记录前端不得显示这些操作按钮,最终按发布配置停用 |
|
||||
| #37 企微应用 | `POST/GET /api/admin/wecom/applications`;`POST /api/admin/wecom/applications/:id/test` | 配置 corp_id、agent_id、Secret、回调 Token、EncodingAESKey;管理端按明文填写 |
|
||||
| #37 默认发起人和成员 | `POST /api/admin/wecom/applications/:id/members/sync`;`GET /api/admin/wecom/applications/:id/members`;`PUT /api/admin/wecom/applications/:id/default-creator` | 先同步成员,再从可见成员中选择默认 `userid` |
|
||||
| #37 账号绑定 | `PUT /api/admin/accounts/:id/wecom-binding` | 管理员选择应用可见成员,绑定 `(corp_id,userid)`;不做扫码绑定 |
|
||||
| #37 场景模板 | `PUT /api/admin/wecom/scenes/:business_type`;`GET /api/admin/wecom/scenes` | `business_type` 为 `refund_approval` 或 `offline_recharge_approval`;传 `application_id`、`template_id`、`control_mapping[]` |
|
||||
| #37 回调 | `GET/POST /api/callback/wecom/approval/:application_id` | 由企微服务器调用,前端无需调用 |
|
||||
|
||||
#### 3.3.1 企业微信审批 8 个接口的前端调用流程
|
||||
|
||||
企业微信配置页使用 8 个 `/api/admin/wecom` 接口;账号绑定属于账号模块,因此完整闭环是“8 个企微配置接口 + 1 个账号绑定接口”。应用凭据和场景写操作应只向超级管理员开放;前端收到 403 时不要降级绕过。
|
||||
|
||||
| 顺序 | 接口 | 页面动作与调用说明 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `POST /api/admin/wecom/applications` | 保存应用。传 `corp_id`、`agent_id`、`name`、`secret`、`callback_token`、43 位 `encoding_aes_key`、`status=1`。相同 `corp_id + agent_id` 再次提交表示更新;保存响应中的 `id` 是后续 `application_id` |
|
||||
| 2 | `GET /api/admin/wecom/applications?page=1&page_size=20` | 进入配置页或保存后刷新应用列表,展示连接时间、启用状态、默认发起人和凭据是否完整 |
|
||||
| 3 | `POST /api/admin/wecom/applications/{id}/test` | 用户点击“测试连接”时调用;`data.success=true` 只表示成功取得 access_token,不表示通讯录、模板和回调均已配置完成 |
|
||||
| 4 | `POST /api/admin/wecom/applications/{id}/members/sync` | 连接成功后点击“同步成员”;后端拉取该自建应用可见范围,返回 `synced_count` 和 `synced_at` |
|
||||
| 5 | `GET /api/admin/wecom/applications/{id}/members?page=1&page_size=20&keyword=...` | 查询最近同步的本地成员快照,`keyword` 可按姓名或 userid 搜索;用于默认发起人和账号绑定的选择器,不要允许手输一个未同步 userid |
|
||||
| 6 | `PUT /api/admin/wecom/applications/{id}/default-creator` | 从成员选择器取 `userid`,请求体为 `{"userid":"zhangsan"}`。代理等非企微账号提交业务时,企微审批由该成员代为发起,但本地业务提交人仍保持真实账号 |
|
||||
| 7 | `PUT /api/admin/wecom/scenes/{business_type}` | 分别保存退款和线下代充值模板映射;后端会实时读取企微模板详情并校验控件 ID、类型、必填控件和选择项 key,校验失败时页面应保留用户输入并展示后端错误 |
|
||||
| 8 | `GET /api/admin/wecom/scenes?page=1&page_size=20` | 进入场景页或保存后刷新,展示 `business_type_name`、模板名称、状态、最近校验时间和控件映射 |
|
||||
|
||||
应用保存请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"agent_id": 1000002,
|
||||
"name": "测试环境审批应用",
|
||||
"secret": "企微应用Secret",
|
||||
"callback_token": "企微后台配置的回调Token",
|
||||
"encoding_aes_key": "43位EncodingAESKey",
|
||||
"status": 1
|
||||
}
|
||||
```
|
||||
|
||||
场景路径只支持:
|
||||
|
||||
- `refund_approval`:退款审批。
|
||||
- `offline_recharge_approval`:员工线下代充值审批。
|
||||
|
||||
场景保存请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"application_id": 1,
|
||||
"template_id": "企微模板ID",
|
||||
"control_mapping": [
|
||||
{
|
||||
"business_field": "refund_no",
|
||||
"control_id": "Text-xxxxxxxx",
|
||||
"control_type": "Text",
|
||||
"option_mapping": {}
|
||||
}
|
||||
],
|
||||
"status": 1
|
||||
}
|
||||
```
|
||||
|
||||
`control_mapping` 的 `control_id`、`control_type` 和选择项 key 必须来自企微后台已经创建的模板。退款可映射业务字段为 `refund_no`、`order_id`、`order_no`、`asset_identifier`、`asset_type`、`actual_received_amount`、`requested_refund_amount`、`refund_voucher_key`、`refund_reason`、`package_usage_id`、`submitter_id`、`submitter_name`;线下代充值可映射 `recharge_no`、`shop_id`、`shop_name`、`amount`、`amount_cent`、`payment_voucher_key`、`remark`、`submitter_id`、`submitter_name`。企微模板中的必填控件必须全部映射。
|
||||
|
||||
完成成员同步后,账号管理页还要调用:
|
||||
|
||||
```http
|
||||
PUT /api/admin/accounts/{account_id}/wecom-binding
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"application_id": 1,
|
||||
"userid": "zhangsan"
|
||||
}
|
||||
```
|
||||
|
||||
前端完整配置顺序为:保存应用 → 测试连接 → 同步成员 → 查询成员 → 设置默认发起人 → 给需要本人发起审批的系统账号绑定成员 → 保存两个业务场景 → 查询场景确认均已启用。应用可见范围变化后,应重新同步成员并检查默认发起人和账号绑定。
|
||||
|
||||
#### 3.3.2 配置完成后的业务审批流程
|
||||
|
||||
1. 前端继续调用原业务接口创建退款或员工线下代充值,不直接调用企微发起审批接口。
|
||||
2. 后端保存业务单、通用审批实例和提交事件,并异步向企微发起审批;前端根据业务列表/详情的 `approval_provider`、`approval_status`、`approval_status_name` 只读展示进度。
|
||||
3. `approval_status` 可能为:`0` 提交中、`1` 审批中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 通过后撤销、`6` 已删除、`7` 提交失败、`8` 提交结果未知。页面名称优先直接使用 `approval_status_name`。
|
||||
4. 企微回调和 Worker 轮询共同同步最终状态;`GET/POST /api/callback/wecom/approval/{application_id}` 只由企微服务器调用,前端禁止调用。
|
||||
5. `approval_provider=wecom` 或存在 `approval_instance_id` 时,前端不得显示退款通过/驳回、线下充值确认等旧人工按钮。企微通过后,退款终结或钱包入账由后端自动幂等执行。
|
||||
|
||||
### 3.4 通知、批量任务、导出和 Gateway
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #97 后台低余额通知 | `GET /api/admin/notifications/unread-count`;`GET /api/admin/notifications`;`PUT /api/admin/notifications/:id/read` | 复用现有后台通知接口;业务员账号正常展示低余额通知 |
|
||||
| #33 临期列表 | `GET /api/admin/expiring-assets` | 支持资产类型、关键词、店铺、套餐、剩余天数和日期范围;响应含 `summary`、`expiry_level`、`is_priority` |
|
||||
| #33 C 端提醒 | `GET /api/c/v1/notifications/unread-count`;`GET /api/c/v1/notifications` | 复用现有站内通知,前端在 15/7/3 天节点按未读通知弹窗 |
|
||||
| #36 批量订购上传 | `POST /api/admin/storage/upload-url` | `purpose=batch_purchase`,上传 UTF-8 单列 CSV 后取得 `file_key` |
|
||||
| #36 批量订购任务 | `POST /api/admin/asset-package-batch-orders`;`GET /api/admin/asset-package-batch-orders`;`GET /api/admin/asset-package-batch-orders/:id` | 创建传 `file_key + package_id + payment_method`,线下支付另传 `voucher_keys[]`;详情返回逐行结果 |
|
||||
| #42 业务导出 | `POST /api/admin/export-tasks`;`GET /api/admin/export-tasks`;`GET /api/admin/export-tasks/:id` | `scene` 使用 `iot_card/package/agent_wallet_transaction/agent_recharge/refund/exchange`,筛选条件放 `query` |
|
||||
| #47 卡限速 | `PUT /api/admin/iot-cards/:iccid/speed-tier` | 请求只传 `code=-1..8`;设备页面不要展示限速入口 |
|
||||
| #48 后台支付配置 | `GET /api/admin/system-configs`;`PUT /api/admin/system-configs/:key` | Key 为 `c2b.payment.card_allowed_methods`、`c2b.payment.device_allowed_methods`;更新请求的 `value` 是 JSON 数组字符串 |
|
||||
| #48 C 端支付 | `GET /api/c/v1/asset/info`;`GET /api/c/v1/wallet/recharge-check`;`POST /api/c/v1/orders/create`;`POST /api/c/v1/wallet/recharge` | 展示和提交都使用后端返回的 `allowed_payment_methods`;创建订单时 `payment_method` 必传,微信场景按接口要求传 `app_type` |
|
||||
| #49 分配 CSV 上传 | `POST /api/admin/storage/upload-url` | `purpose=device_batch_allocation`,上传单列 CSV 后取得 `file_key` |
|
||||
| #49 创建设备分配任务 | `POST /api/admin/devices/import/allocations` | 传 `file_key + operation_type + target_id`;`operation_type=assign_shop|assign_series` |
|
||||
| #49 查询任务 | `GET /api/admin/devices/import/tasks`;`GET /api/admin/devices/import/tasks/:id` | 复用原设备导入任务页面,新增展示 `operation_type`、`operation_name`、`target_id`、`status_name` |
|
||||
|
||||
### 3.5 前端静态 CSV 模板
|
||||
|
||||
本期需要前端提供两份静态文件模板,**都是 CSV,不接受 Excel(`.xls`/`.xlsx`)**。文件使用 UTF-8 编码,允许 UTF-8 BOM,最大 10MB,最多 1000 行数据(不含表头)。每个文件只允许一列,不要添加空行、说明行或示例外的其他列。
|
||||
|
||||
#### 模板一:批量订购套餐
|
||||
|
||||
- 建议文件名:`批量订购套餐模板.csv`
|
||||
- 上传用途:`purpose=batch_purchase`
|
||||
- 套餐、支付方式和线下凭证由页面另外选择,不放在 CSV 中。
|
||||
|
||||
| 列序号 | 固定表头 | 必填 | 填写内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `资产标识` | 是 | 每行一个资产标识。卡支持 ICCID、VirtualNo 或 MSISDN;设备支持 VirtualNo、IMEI 或 SN |
|
||||
|
||||
```csv
|
||||
资产标识
|
||||
89860012345678901234
|
||||
CARD-VIRTUAL-0001
|
||||
DEVICE-VIRTUAL-0001
|
||||
860123456789012
|
||||
```
|
||||
|
||||
#### 模板二:设备批量分配
|
||||
|
||||
- 建议文件名:`设备批量分配模板.csv`
|
||||
- 上传用途:`purpose=device_batch_allocation`
|
||||
- 目标代理或套餐系列由页面另外选择,不放在 CSV 中。“分配代理”和“设置套餐系列”可以共用这一份模板。
|
||||
|
||||
| 列序号 | 固定表头 | 必填 | 填写内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `设备标识` | 是 | 每行一个设备标识,支持 VirtualNo、IMEI 或 SN |
|
||||
|
||||
```csv
|
||||
设备标识
|
||||
DEVICE-VIRTUAL-0001
|
||||
860123456789012
|
||||
SN202607250001
|
||||
```
|
||||
|
||||
> 前端下载的静态模板可以只保留表头,上述数据行仅用于说明格式。资产标识必须按文本原样保存,不得转换为科学计数法、浮点数或截断前导零。
|
||||
|
||||
## 四、前端本期最容易漏掉的工作
|
||||
|
||||
- 换货列表拆成新、旧资产两个搜索参数。
|
||||
- 资产预计到期展示使用“预计最终到期”,并按临期字段高亮。
|
||||
- 套餐分配页面传递默认/覆盖生效条件。
|
||||
- 店铺页面接入联系电话查询、业务员选择和 C 端登录限制开关。
|
||||
- 系列套餐授权页面真正使用现有 `packages[]` 做多选提交。
|
||||
- C 端实名流程只读取 `effective_realname_policy`。
|
||||
- C 端支付按钮只读取 `allowed_payment_methods`。
|
||||
- 历史订单续费继续调用原创建订单接口,不等待新续费接口。
|
||||
- 企微退款和线下代充值只读展示审批状态,隐藏本地人工审批按钮。
|
||||
- 卡页面提供固定档位限速;设备页面不得出现限速入口。
|
||||
- 设备批量分配继续复用原设备导入任务列表/详情页面,但根据 `operation_type` 改标题和结果说明。
|
||||
|
||||
## 五、联调和验收边界
|
||||
|
||||
- 当前有两个需要在联调时明确的接口边界:
|
||||
- #44:退款/充值列表已经返回提交人和审批状态,但没有直接返回企微审批节点人员列表;现有 userid 映射能力尚未接入这两个业务 DTO。
|
||||
- #49:设备批量分配创建服务允许平台和代理账号,但复用的设备导入任务列表/详情 Handler 目前仍沿用“仅平台用户可查看”的旧限制。若本期只允许平台操作则前端应隐藏代理入口;若要求代理自行查看任务,需要再统一后端权限。
|
||||
- OpenAPI 已生成,新增路径和 DTO 已检查;设备限速旧契约不存在。
|
||||
- 本次没有执行数据库迁移、完整构建、自动化测试、LSP 或真实企微/Gateway 闭环。
|
||||
- 企微、Gateway、Redis/Asynq、对象存储配置和回滚步骤见 [`七月迭代联调交付说明.md`](七月迭代联调交付说明.md)。
|
||||
- 涉及金额的字段默认单位为分;前端展示时统一转换,提交时不要传浮点元金额。
|
||||
54
docs/产品迭代7月份/个人客户站内通知.md
Normal file
54
docs/产品迭代7月份/个人客户站内通知.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 个人客户站内通知
|
||||
|
||||
为个人客户增加通知入口,支持查询通知、查看未读数以及标记已读。进入首页时自动查询未读通知并弹窗展示;多条通知使用左右滑动查看,弹窗打开时第一条自动标记已读,滑动查看其他通知时立即标记对应通知已读。弹窗支持关闭。所有接口均需要登录,使用 `Bearer Token` 鉴权。
|
||||
|
||||
## 查询通知列表
|
||||
|
||||
`GET /api/c/v1/notifications`
|
||||
|
||||
查询当前客户可见的未过期业务通知,按创建时间和通知 ID 倒序排列。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `page` | 否 | 页码,默认 1,范围 1~10000 |
|
||||
| `page_size` | 否 | 每页数量,默认 20,范围 1~50 |
|
||||
| `is_read` | 否 | 已读状态;`false` 仅查询未读,`true` 仅查询已读,不传时查询全部 |
|
||||
|
||||
返回:包含通知列表 `items`、当前页 `page`、每页数量 `size` 和总数 `total`。通知项包含标题、正文、类型、类别、级别、关联资源、已读状态及创建/已读时间。
|
||||
|
||||
## 标记单条通知已读
|
||||
|
||||
`PUT /api/c/v1/notifications/{id}/read`
|
||||
|
||||
将当前客户可见的指定通知标记为已读。通知不存在、属于其他客户或已经已读时,也返回成功,接口幂等。
|
||||
|
||||
返回:`{ "success": true }`
|
||||
|
||||
## 全部标记已读
|
||||
|
||||
`PUT /api/c/v1/notifications/read-all`
|
||||
|
||||
将当前客户可见的未过期未读业务通知全部标记为已读,重复调用幂等。
|
||||
|
||||
返回:`{ "updated_count": 3 }`
|
||||
|
||||
## 查询未读数
|
||||
|
||||
`GET /api/c/v1/notifications/unread-count`
|
||||
|
||||
查询当前客户未过期业务通知的未读数量;平台同步和系统运维通知不计入结果。
|
||||
|
||||
返回:`{ "count": 3, "display_count": "3" }`,数量超过 99 时 `display_count` 为 `99+`。
|
||||
|
||||
## 首页未读通知弹窗
|
||||
|
||||
- 用户进入首页后,调用 `GET /api/c/v1/notifications`,传入 `is_read=false`、`page=1`、`page_size=50`,只展示当前未读通知。
|
||||
- 有多条未读通知时默认展示第一条,弹窗打开后立即调用单条已读接口标记第一条。
|
||||
- 用户左右滑动切换到其他通知时,视为已查看并立即调用单条已读接口标记当前通知。
|
||||
- 点击关闭图标只关闭弹窗,不会将未查看的其他通知批量标记为已读;下次进入首页仍可继续展示剩余未读通知。
|
||||
- 通知的 `ref_type`、`ref_id`、`ref_key` 仅用于资源定位或展示,不直接拼接页面路由。
|
||||
|
||||
## 通用约定
|
||||
|
||||
- 所有接口均使用 `Bearer Token` 鉴权。
|
||||
- 未登录、参数错误、无权访问和服务异常沿用统一错误响应。
|
||||
1779
docs/产品迭代8月份/11.md
Normal file
1779
docs/产品迭代8月份/11.md
Normal file
File diff suppressed because it is too large
Load Diff
15
docs/产品迭代8月份/支付.md
Normal file
15
docs/产品迭代8月份/支付.md
Normal file
File diff suppressed because one or more lines are too long
4
docs/充值.md
Normal file
4
docs/充值.md
Normal file
@@ -0,0 +1,4 @@
|
||||
我的钱包里面的账户充值里的确认充值按钮、我的钱包下面充值订单里的立即支付、套餐列表选择支付方式里的确认支付、我的订单里面的立即支付,都需要加上防抖。
|
||||
|
||||
OpenSpec 变更提案位置:
|
||||
- `openspec/changes/add-payment-submit-guard/`
|
||||
187
docs/微信扫一扫配置文档.md
Normal file
187
docs/微信扫一扫配置文档.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# uniapp H5 调用微信扫一扫配置文档
|
||||
|
||||
## 一、微信 JSSDK 加载
|
||||
|
||||
**方式:通过 CDN 加载微信官方 JSSDK**
|
||||
|
||||
在 `index.html` 的 `<head>` 中添加:
|
||||
|
||||
```html
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
```
|
||||
|
||||
> **注意:** 不要使用 `import wx from 'jweixin-module'` 或 npm 包方式导入,uni-app 会错误解析导致构建失败。
|
||||
|
||||
---
|
||||
|
||||
## 二、wxsdk.js 封装
|
||||
|
||||
创建 `utils/wxsdk.js`:
|
||||
|
||||
```javascript
|
||||
// utils/wxsdk.js
|
||||
import { wechatApi } from '@/api/index.js'
|
||||
|
||||
// 注意:是 jWeixin,不是 wx
|
||||
// window.wx 会被 uni-app 框架占用为 Vue 模块
|
||||
const wx = window.jWeixin
|
||||
|
||||
/**
|
||||
* 初始化微信 JS-SDK
|
||||
*/
|
||||
export async function initWxConfig() {
|
||||
const url = window.location.href.split('#')[0]
|
||||
try {
|
||||
const data = await wechatApi.getJssdkConfig(url)
|
||||
const { app_id, timestamp, nonce_str, signature } = data
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.config({
|
||||
debug: true,
|
||||
appId: app_id,
|
||||
timestamp: timestamp,
|
||||
nonceStr: nonce_str,
|
||||
signature: signature,
|
||||
jsApiList: ['scanQRCode']
|
||||
})
|
||||
wx.ready(() => {
|
||||
console.log('微信 SDK 就绪')
|
||||
resolve()
|
||||
})
|
||||
wx.error((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起微信扫一扫
|
||||
* @returns {Promise<string>} 扫码结果字符串
|
||||
*/
|
||||
export function wxScan() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.scanQRCode({
|
||||
needResult: 1,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success(res) {
|
||||
resolve(res.resultStr)
|
||||
},
|
||||
fail(err) {
|
||||
reject(new Error(err?.errMsg || '扫码失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在微信环境中
|
||||
*/
|
||||
export function isInWechat() {
|
||||
return /micromessenger/i.test(navigator.userAgent)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、后端接口要求
|
||||
|
||||
后端需提供 `/api/c/v1/wechat/jssdk-config` 接口,返回格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"app_id": "wx1234567890",
|
||||
"timestamp": 1234567890,
|
||||
"nonce_str": "randomstring",
|
||||
"signature": "xxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意:** `nonce_str` 必须与生成 signature 时使用的一致
|
||||
|
||||
---
|
||||
|
||||
## 四、前端调用示例
|
||||
|
||||
```javascript
|
||||
import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js'
|
||||
|
||||
async function handleScan() {
|
||||
if (!isInWechat()) {
|
||||
uni.showToast({ title: '请在微信环境中使用', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 初始化微信 SDK 配置
|
||||
await initWxConfig()
|
||||
|
||||
// 2. 调起扫一扫
|
||||
const result = await wxScan()
|
||||
console.log('扫码结果:', result)
|
||||
// 在此处理扫码结果
|
||||
} catch (err) {
|
||||
console.error('扫码错误:', err)
|
||||
uni.showToast({ title: err.message || JSON.stringify(err), icon: 'none' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `window.wx` 输出 `Module {$emit...}` | `window.wx` 被 uni-app 框架占用为 Vue 事件模块 | 使用 `window.jWeixin` |
|
||||
| `jweixin-module` 打包报 `"default" is not exported` | uni 构建系统错误解析无 default export 的 UMD 模块 | 使用 CDN 加载,不走 npm |
|
||||
| `wx.scanQRCode is not a function` | 微信 SDK 未正确加载 | 确认 `window.jWeixin` 有值 |
|
||||
| 扫码提示 `{}` | `catch` 捕获空错误对象 | 需将错误信息转换为 `Error` 对象返回 |
|
||||
|
||||
---
|
||||
|
||||
## 六、调试建议
|
||||
|
||||
在微信开发者工具中打开控制台,检查以下变量:
|
||||
|
||||
```javascript
|
||||
// 错误情况:被 uni-app 框架占用为 Vue 模块
|
||||
window.wx
|
||||
// 输出:Module {$emit: ƒ, $off: ƒ, $on: ƒ, $once: ƒ, ...}
|
||||
|
||||
// 正确情况:微信 JSSDK
|
||||
window.jWeixin
|
||||
// 输出:{config: ƒ, ready: ƒ, error: ƒ, checkJsApi: ƒ, ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、完整文件路径
|
||||
|
||||
```
|
||||
project/
|
||||
├── index.html # 引入微信 JSSDK CDN
|
||||
├── utils/
|
||||
│ └── wxsdk.js # 微信扫一扫封装
|
||||
└── api/
|
||||
└── modules/
|
||||
└── wechat.js # 后端接口封装
|
||||
```
|
||||
|
||||
`api/modules/wechat.js` 示例:
|
||||
|
||||
```javascript
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
export const wechatApi = {
|
||||
getJssdkConfig(url) {
|
||||
return request({
|
||||
url: '/api/c/v1/wechat/jssdk-config',
|
||||
method: 'GET',
|
||||
data: { url }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
277
docs/所需接口文档/new-api.md
Normal file
277
docs/所需接口文档/new-api.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# C 端所需接口文档
|
||||
|
||||
## 1. 通用约定
|
||||
|
||||
- 测试环境:`https://cmp-api.boss160.cn`
|
||||
- 鉴权:除特别说明外,所有接口都需要登录后的 JWT。
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
- `identifier`:资产标识符,可传 SN、IMEI、虚拟号、ICCID 或 MSISDN,长度 1~50。
|
||||
- 金额单位:分。页面展示时转换为元,提交时仍传整数分。
|
||||
- 成功响应:`code = 0`。
|
||||
- 错误响应:`400` 参数错误、`401` 未认证或过期、`403` 无权访问、`500` 服务端错误。
|
||||
|
||||
通用成功响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {},
|
||||
"msg": "success",
|
||||
"timestamp": "2026-07-27T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
错误响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1001,
|
||||
"data": {},
|
||||
"msg": "参数验证失败",
|
||||
"timestamp": "2026-07-27T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 接口清单
|
||||
|
||||
| 用途 | 方法 | 路径 |
|
||||
| --- | --- | --- |
|
||||
| 获取资产信息 | GET | `/api/c/v1/asset/info` |
|
||||
| 充值前校验 | GET | `/api/c/v1/wallet/recharge-check` |
|
||||
| 创建套餐订单 | POST | `/api/c/v1/orders/create` |
|
||||
| 创建充值订单 | POST | `/api/c/v1/wallet/recharge` |
|
||||
| 订单列表 | GET | `/api/c/v1/orders` |
|
||||
| 订单详情 | GET | `/api/c/v1/orders/{id}` |
|
||||
|
||||
## 3. 获取资产信息
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info?identifier=1234567890
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `identifier` | string | 是 | 资产标识符,1~50 个字符 |
|
||||
|
||||
### `data` 关键返回字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `asset_id` | integer | 资产 ID |
|
||||
| `asset_type` | string | `card` 卡、`device` 设备 |
|
||||
| `identifier` | string | 当前资产标识符 |
|
||||
| `iccid` / `msisdn` | string | 卡 ICCID / 手机号 |
|
||||
| `sn` / `imei` / `virtual_no` | string | 设备序列号 / IMEI / 虚拟号 |
|
||||
| `device_name` / `device_model` | string | 设备名称 / 型号 |
|
||||
| `carrier_name` / `carrier_type` | string | 运营商名称 / 类型(`CMCC`、`CUCC`、`CTCC`、`CBN`) |
|
||||
| `status` / `status_name` | integer / string | 资产归属状态:`1` 在库、`2` 已分销;卡沿用卡状态枚举 |
|
||||
| `activation_status` / `activation_status_name` | integer / string | 激活状态:`0` 未激活、`1` 已激活 |
|
||||
| `network_status` / `network_status_name` | integer / string | 网络状态:`0` 停机、`1` 开机 |
|
||||
| `real_name_status` / `real_name_status_name` | integer / string | 实名状态:`0` 未实名、`1` 已实名 |
|
||||
| `realname_policy` | string | 实名策略:`none`、`before_order`、`after_order` |
|
||||
| `effective_realname_policy` | string | 当前实际生效的实名策略 |
|
||||
| `realname_required` | boolean | 当前资产是否需要实名 |
|
||||
| `allowed_payment_methods` | string[] | 允许的支付方式:`wallet`、`wechat`、`alipay` |
|
||||
| `wallet_balance` | integer | 钱包余额,单位为分 |
|
||||
| `current_package_id` | integer | 当前主套餐 ID;无套餐时为 `0`,可用于续费 |
|
||||
| `current_package` | string | 当前套餐名称,无套餐时为空 |
|
||||
| `current_package_activated_at` | datetime / null | 当前主套餐开始时间 |
|
||||
| `current_package_expires_at` | datetime / null | 当前主套餐到期时间 |
|
||||
| `estimated_final_expires_at` | datetime / null | 预计最终到期时间 |
|
||||
| `days_until_final_expiry` | integer/null | 距预计最终到期的上海自然日天数 |
|
||||
| `expiry_estimate_status` | string | `exact` 精确、`waiting_activation` 待激活、`none` 无套餐、`invalid_data` 数据异常 |
|
||||
| `is_expiring` | boolean | 是否临期(精确推算且剩余 0~15 天) |
|
||||
| `enable_virtual_data` | boolean | 当前主套餐是否启用虚流量 |
|
||||
| `real_total_mb` / `real_used_mb` | integer | 真实总量 / 真实已用量,单位 MB |
|
||||
| `virtual_total_mb` / `virtual_used_mb` | integer / number | 业务停机阈值 / 展示已用量,单位 MB |
|
||||
| `reduction_pct` | number | 展示增幅比例:`real_total_mb / virtual_total_mb - 1` |
|
||||
| `cards` | object[] | 设备绑定卡列表,包含 ICCID、MSISDN、网络状态、实名状态、插槽位置等 |
|
||||
| `device_realtime` | object/null | 设备实时信息,包含在线状态、电量、信号、WiFi、客户端数等 |
|
||||
|
||||
页面流量展示建议:总量使用 `real_total_mb`;已用量在 `enable_virtual_data = true` 时使用 `virtual_used_mb`,否则使用 `real_used_mb`。
|
||||
|
||||
## 4. 充值前校验
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /api/c/v1/wallet/recharge-check?identifier=1234567890
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `identifier` | string | 是 | 资产标识符 |
|
||||
|
||||
### `data` 返回字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `allowed_payment_methods` | string[]/null | 当前允许的支付方式:`wallet`、`wechat`、`alipay` |
|
||||
| `need_force_recharge` | boolean | 是否必须先完成强制充值 |
|
||||
| `force_recharge_amount` | integer | 强制充值金额,单位为分 |
|
||||
| `min_amount` | integer | 最小充值金额,单位为分 |
|
||||
| `max_amount` | integer | 最大充值金额,单位为分 |
|
||||
| `message` | string | 页面提示信息 |
|
||||
| `trigger_type` | string | 强制充值触发类型 |
|
||||
|
||||
## 5. 创建套餐订单
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"identifier": "1234567890",
|
||||
"package_ids": [1001, 1002],
|
||||
"payment_method": "wechat",
|
||||
"app_type": "miniapp"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `identifier` | string | 是 | 资产标识符 |
|
||||
| `package_ids` | integer[] | 是 | 套餐 ID 列表 |
|
||||
| `payment_method` | string | 是 | `wallet`、`wechat`、`alipay` |
|
||||
| `app_type` | string | 微信支付时是 | `official_account` 公众号、`miniapp` 小程序 |
|
||||
|
||||
### `data` 返回字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `idempotent` | boolean | 是否返回了已存在的待支付订单 |
|
||||
| `order_type` | string | `package` 套餐订单、`recharge` 充值订单 |
|
||||
| `order` | object | 订单信息,见下表 |
|
||||
| `linked_package_info` | object | 关联套餐和强制充值信息 |
|
||||
| `recharge` | object/null | 自动充值信息 |
|
||||
| `pay_config` | object/null | 微信支付参数 |
|
||||
| `payment_link` | object/null | 支付宝或其他网页支付链接 |
|
||||
|
||||
`order` 主要字段:`order_id`、`order_no`、`created_at`、`payment_method`、`payment_status`、`payment_status_name`、`total_amount`。
|
||||
|
||||
- `payment_status`:`1` 待支付、`2` 已支付、`3` 已取消、`4` 已退款。
|
||||
- `linked_package_info`:`force_recharge_amount`、`package_names`、`total_package_amount`、`wallet_credit`,金额均为分。
|
||||
- `recharge`:`recharge_id`、`recharge_no`、`amount`、`status`、`status_name`、`auto_purchase_status`。
|
||||
- `pay_config`:`app_id`、`nonce_str`、`package`、`pay_sign`、`sign_type`、`timestamp`。
|
||||
- `payment_link`:`copy_link`、`qr_link`、`payment_no`、`pay_expire_at`。
|
||||
|
||||
## 6. 创建充值订单
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /api/c/v1/wallet/recharge
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 1000,
|
||||
"identifier": "1234567890",
|
||||
"payment_method": "wechat",
|
||||
"app_type": "miniapp"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `amount` | integer | 是 | 充值金额,1~10000000 分 |
|
||||
| `identifier` | string | 是 | 资产标识符 |
|
||||
| `payment_method` | string | 是 | `wechat` 微信支付、`alipay` 支付宝 |
|
||||
| `app_type` | string | 微信支付时是 | `official_account` 或 `miniapp` |
|
||||
|
||||
### `data` 返回字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `recharge` | object | 充值信息:`recharge_id`、`recharge_no`、`amount`、`status` |
|
||||
| `pay_config` | object/null | 微信支付参数:`app_id`、`nonce_str`、`package`、`pay_sign`、`sign_type`、`timestamp` |
|
||||
| `payment_link` | object/null | 支付链接:`copy_link`、`qr_link`、`payment_no`、`pay_expire_at` |
|
||||
|
||||
`recharge.status`:`0` 待支付、`1` 已支付、`2` 已关闭。
|
||||
|
||||
## 7. 订单列表
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /api/c/v1/orders?identifier=1234567890&page=1&page_size=10
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `identifier` | string | 是 | 资产标识符 |
|
||||
| `payment_status` | integer | 否 | `1` 待支付、`2` 已支付、`3` 已取消、`4` 已退款 |
|
||||
| `page` | integer | 是 | 页码,从 1 开始 |
|
||||
| `page_size` | integer | 是 | 每页数量,1~100 |
|
||||
|
||||
### `data` 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"size": 10,
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
`items[]` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `order_id` / `order_no` | integer / string | 订单 ID / 订单号 |
|
||||
| `asset_id` / `asset_type` | integer / string | 资产 ID / `card` 或 `device` |
|
||||
| `asset_identifier` | string | 下单时资产标识快照;设备优先虚拟号,其次 IMEI |
|
||||
| `package_ids` / `package_names` | array | 套餐 ID / 名称列表 |
|
||||
| `payment_method` | string | `wallet`、`wechat`、`alipay` |
|
||||
| `payment_status` / `payment_status_name` | integer / string | 支付状态及中文名称 |
|
||||
| `total_amount` | integer | 订单总金额,单位为分 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
|
||||
## 8. 订单详情
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /api/c/v1/orders/{id}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | integer | 是 | 订单 ID,放在 URL 路径中 |
|
||||
|
||||
### `data` 返回字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `order_id` / `order_no` | integer / string | 订单 ID / 订单号 |
|
||||
| `asset_id` / `asset_type` | integer / string | 资产 ID / `card` 或 `device` |
|
||||
| `asset_identifier` | string | 下单时资产标识快照 |
|
||||
| `packages` | object[] | 订单套餐明细 |
|
||||
| `payment_method` | string | 支付方式 |
|
||||
| `payment_status` / `payment_status_name` | integer / string | 支付状态及中文名称 |
|
||||
| `total_amount` | integer | 订单总金额,单位为分 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `paid_at` | string/null | 支付时间 |
|
||||
| `completed_at` | string/null | 完成时间 |
|
||||
|
||||
`packages[]` 字段:`package_id`、`package_name`、`package_type`(`formal` 正式套餐、`addon` 加油包)、`price`(分)、`quantity`。
|
||||
|
||||
## 9. 前端调用流程
|
||||
|
||||
1. 登录后保存 JWT,后续请求统一携带 `Authorization: Bearer <token>`。
|
||||
2. 进入资产页面调用资产信息接口,并使用返回的 `allowed_payment_methods` 渲染支付方式。
|
||||
3. 充值先调用充值前校验;若 `need_force_recharge = true`,使用后端返回的强制充值金额和提示。
|
||||
4. 套餐购买或续费调用创建订单接口,`package_ids` 使用当前资产的 `current_package_id` 或历史订单的 `package_ids`。
|
||||
5. 充值调用创建充值订单接口。微信支付时必须传 `app_type`。
|
||||
6. 页面展示支付链接或支付参数后,支付结果通过订单详情等查询接口确认;不要仅根据前端跳转结果判断已支付。
|
||||
436
docs/换货.md
Normal file
436
docs/换货.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# 换货流程升级接口变更说明
|
||||
|
||||
## 一、数据库字段新增
|
||||
|
||||
### 表:`tb_exchange_order`
|
||||
|
||||
新增以下字段,所有换货单相关接口响应均会返回:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------ | ------------- | ---------------------------------------------------------- |
|
||||
| flow_type | string | 流程类型:`shipping`(物流换货)或 `direct`(直接换货),历史数据为空时视为 `shipping` |
|
||||
| shipped_at | string | null | 发货时间,仅 `shipping` 流程发货后有值 |
|
||||
| completed_at | string | null | 换货完成时间,换货完成后有值 |
|
||||
|
||||
---
|
||||
|
||||
# 二、后台接口变更
|
||||
|
||||
## 1. 创建换货单
|
||||
|
||||
### POST /api/admin/exchanges
|
||||
|
||||
### 新增请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"old_asset_type": "iot_card",
|
||||
"old_identifier": "xxx",
|
||||
"exchange_reason": "设备损坏",
|
||||
"flow_type": "shipping",
|
||||
"new_identifier": "yyy",
|
||||
"migrate_data": false,
|
||||
"remark": null
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| -------------- | ------------- | ---------- | ---------------------------------------- |
|
||||
| flow_type | string | 否 | 流程类型:`shipping` / `direct`,默认 `shipping` |
|
||||
| new_identifier | string | direct 时必填 | 新资产标识符 |
|
||||
| migrate_data | boolean | 否 | 是否迁移数据,默认 false |
|
||||
| remark | string | null | 否 | 备注 |
|
||||
|
||||
### 不同流程行为差异
|
||||
|
||||
| flow_type | 创建后状态(status) | completed_at |
|
||||
| --------- | ------------- | ------------ |
|
||||
| shipping | 1(待填写信息) | null |
|
||||
| direct | 4(已完成) | 当前时间 |
|
||||
|
||||
### 说明
|
||||
|
||||
* shipping 为原有流程
|
||||
* direct 创建后直接完成
|
||||
* direct 不需要填写地址
|
||||
* direct 不需要发货
|
||||
* direct 不需要确认完成
|
||||
* 非法 flow_type 返回参数错误
|
||||
|
||||
---
|
||||
|
||||
## 2. 换货单列表
|
||||
|
||||
### GET /api/admin/exchanges
|
||||
|
||||
### 新增查询参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ---------- | ------ | ------------------------ |
|
||||
| flow_type | string | 按流程类型筛选(shipping/direct) |
|
||||
| identifier | string | 资产标识符模糊查询(同时匹配旧资产、新资产) |
|
||||
|
||||
### 响应新增字段
|
||||
|
||||
```json
|
||||
{
|
||||
"flow_type": "shipping",
|
||||
"flow_type_name": "物流换货",
|
||||
"shipped_at": null,
|
||||
"completed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 换货单详情
|
||||
|
||||
### GET /api/admin/exchanges/:id
|
||||
|
||||
### 响应新增字段
|
||||
|
||||
```json
|
||||
{
|
||||
"flow_type": "shipping",
|
||||
"flow_type_name": "物流换货",
|
||||
"shipped_at": null,
|
||||
"completed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
对于 direct 类型:
|
||||
|
||||
* 收货信息为空
|
||||
* 物流信息为空
|
||||
|
||||
属于正常情况,前端需根据 flow_type 控制相关区块显示。
|
||||
|
||||
---
|
||||
|
||||
## 4. 发货
|
||||
|
||||
### POST /api/admin/exchanges/:id/ship
|
||||
|
||||
### 新增限制
|
||||
|
||||
仅支持:
|
||||
|
||||
```text
|
||||
flow_type = shipping
|
||||
```
|
||||
|
||||
### 不允许情况
|
||||
|
||||
```text
|
||||
flow_type = direct
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "流程类型不支持该操作"
|
||||
}
|
||||
```
|
||||
|
||||
### 前端处理
|
||||
|
||||
```javascript
|
||||
exchange.flow_type === 'shipping'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 确认完成
|
||||
|
||||
### POST /api/admin/exchanges/:id/complete
|
||||
|
||||
### 新增限制
|
||||
|
||||
仅支持:
|
||||
|
||||
```text
|
||||
flow_type = shipping
|
||||
status = 3
|
||||
```
|
||||
|
||||
### 不允许情况
|
||||
|
||||
```text
|
||||
flow_type = direct
|
||||
```
|
||||
|
||||
返回错误。
|
||||
|
||||
---
|
||||
|
||||
## 6. 取消换货
|
||||
|
||||
### POST /api/admin/exchanges/:id/cancel
|
||||
|
||||
### 新增限制
|
||||
|
||||
允许取消:
|
||||
|
||||
```text
|
||||
flow_type = shipping
|
||||
status IN (1, 2)
|
||||
```
|
||||
|
||||
### 不允许取消
|
||||
|
||||
#### direct 单据
|
||||
|
||||
```text
|
||||
flow_type = direct
|
||||
```
|
||||
|
||||
#### 已发货单据
|
||||
|
||||
```text
|
||||
flow_type = shipping
|
||||
status = 3
|
||||
```
|
||||
|
||||
返回错误。
|
||||
|
||||
---
|
||||
|
||||
# 三、客户端接口变更
|
||||
|
||||
## 1. 查询进行中的换货单
|
||||
|
||||
### GET /api/c/v1/exchange/pending
|
||||
|
||||
### 变更后返回规则
|
||||
|
||||
仅返回:
|
||||
|
||||
```text
|
||||
flow_type = shipping
|
||||
status IN (1, 2, 3)
|
||||
```
|
||||
|
||||
### 不返回
|
||||
|
||||
```text
|
||||
flow_type = direct
|
||||
```
|
||||
|
||||
客户端无需特殊处理。
|
||||
|
||||
---
|
||||
|
||||
## 2. 填写收货地址
|
||||
|
||||
### POST /api/c/v1/exchange/:id/shipping-info
|
||||
|
||||
### 新增限制
|
||||
|
||||
仅支持:
|
||||
|
||||
```text
|
||||
flow_type = shipping
|
||||
status = 1
|
||||
```
|
||||
|
||||
### 不允许
|
||||
|
||||
```text
|
||||
flow_type = direct
|
||||
```
|
||||
|
||||
返回错误。
|
||||
|
||||
---
|
||||
|
||||
# 四、前端 UI 改造建议
|
||||
|
||||
## 换货单详情页
|
||||
|
||||
### 新增展示字段
|
||||
|
||||
* 流程类型(flow_type_name)
|
||||
* 完成时间(completed_at)
|
||||
* 发货时间(shipped_at)
|
||||
|
||||
### 按流程类型控制按钮
|
||||
|
||||
#### direct
|
||||
|
||||
不显示:
|
||||
|
||||
* 发货
|
||||
* 确认完成
|
||||
* 取消
|
||||
|
||||
#### shipping + status = 1
|
||||
|
||||
显示:
|
||||
|
||||
* 取消
|
||||
|
||||
#### shipping + status = 2
|
||||
|
||||
显示:
|
||||
|
||||
* 发货
|
||||
* 取消
|
||||
|
||||
#### shipping + status = 3
|
||||
|
||||
显示:
|
||||
|
||||
* 确认完成
|
||||
|
||||
### 信息区块控制
|
||||
|
||||
#### direct
|
||||
|
||||
隐藏:
|
||||
|
||||
* 收货信息
|
||||
* 物流信息
|
||||
|
||||
或者显示:
|
||||
|
||||
```text
|
||||
不适用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 创建换货单弹窗
|
||||
|
||||
### 新增字段
|
||||
|
||||
流程类型:
|
||||
|
||||
* 物流换货(shipping)
|
||||
* 直接换货(direct)
|
||||
|
||||
### 选择 direct 时
|
||||
|
||||
显示:
|
||||
|
||||
* 新资产标识符(必填)
|
||||
* 是否迁移数据(开关)
|
||||
|
||||
对应参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"new_identifier": "xxx",
|
||||
"migrate_data": true
|
||||
}
|
||||
```
|
||||
|
||||
### 选择 shipping 时
|
||||
|
||||
隐藏:
|
||||
|
||||
* 新资产标识符
|
||||
* 是否迁移数据
|
||||
|
||||
---
|
||||
|
||||
## 换货单列表页
|
||||
|
||||
### 新增表格列
|
||||
|
||||
```text
|
||||
流程类型
|
||||
```
|
||||
|
||||
显示:
|
||||
|
||||
```text
|
||||
物流换货
|
||||
直接换货
|
||||
```
|
||||
|
||||
### 新增筛选项
|
||||
|
||||
```text
|
||||
流程类型
|
||||
```
|
||||
|
||||
下拉选项:
|
||||
|
||||
```text
|
||||
全部
|
||||
物流换货(shipping)
|
||||
直接换货(direct)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 五、流程状态说明
|
||||
|
||||
## shipping(物流换货)
|
||||
|
||||
```text
|
||||
创建
|
||||
↓
|
||||
待填写信息(1)
|
||||
↓
|
||||
待发货(2)
|
||||
↓
|
||||
已发货(3)
|
||||
↓
|
||||
已完成(4)
|
||||
```
|
||||
|
||||
## direct(直接换货)
|
||||
|
||||
```text
|
||||
创建
|
||||
↓
|
||||
已完成(4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 六、状态与操作矩阵
|
||||
|
||||
| flow_type | status | 发货 | 确认完成 | 取消 |
|
||||
| --------- | ------ | -- | ---- | -- |
|
||||
| shipping | 1 | ❌ | ❌ | ✅ |
|
||||
| shipping | 2 | ✅ | ❌ | ✅ |
|
||||
| shipping | 3 | ❌ | ✅ | ❌ |
|
||||
| shipping | 4 | ❌ | ❌ | ❌ |
|
||||
| direct | 4 | ❌ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
# 七、枚举定义
|
||||
|
||||
## flow_type
|
||||
|
||||
```json
|
||||
{
|
||||
"shipping": "物流换货",
|
||||
"direct": "直接换货"
|
||||
}
|
||||
```
|
||||
|
||||
## flow_type_name 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"flow_type": "shipping",
|
||||
"flow_type_name": "物流换货"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"flow_type": "direct",
|
||||
"flow_type_name": "直接换货"
|
||||
}
|
||||
```
|
||||
307
docs/新增支付宝支付.md
Normal file
307
docs/新增支付宝支付.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# 支付宝支付链接前端对接说明
|
||||
|
||||
## 1. 对接范围
|
||||
|
||||
本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK,后端会返回已签名的支付宝 WAP 支付链接。
|
||||
|
||||
覆盖场景:
|
||||
|
||||
1. 套餐订单支付
|
||||
2. 钱包充值
|
||||
3. 强制充值购包场景
|
||||
|
||||
微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`。
|
||||
|
||||
具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。
|
||||
|
||||
## 2. 支付方式取值
|
||||
|
||||
前端统一使用 `payment_method` 区分支付方式:
|
||||
|
||||
| 值 | 含义 | 前端处理 |
|
||||
| --- | --- | --- |
|
||||
| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 |
|
||||
| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 |
|
||||
| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 |
|
||||
|
||||
注意事项:
|
||||
|
||||
- `app_type` 只在微信支付时需要。
|
||||
- 支付宝支付时不需要传 `app_type`。
|
||||
- 支付宝支付返回的是支付链接,不是 JSAPI 参数。
|
||||
|
||||
## 3. 前端需要关注的响应字段
|
||||
|
||||
当选择支付宝支付时,接口响应中会返回 `payment_link`:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_link": {
|
||||
"payment_no": "支付单号",
|
||||
"qr_link": "支付宝支付链接,可用于生成二维码",
|
||||
"copy_link": "支付宝支付链接,可复制或直接打开",
|
||||
"pay_expire_at": "支付链接过期时间,RFC3339 格式"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| `payment_no` | 支付单号,问题排查 / 客服查询使用 |
|
||||
| `qr_link` | 前端可用该链接生成二维码 |
|
||||
| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 |
|
||||
| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 |
|
||||
|
||||
## 4. 推荐交互流程
|
||||
|
||||
### 4.1 支付宝支付流程
|
||||
|
||||
前端拿到 `payment_link` 后:
|
||||
|
||||
1. H5 / 浏览器场景:直接跳转 `copy_link`。
|
||||
|
||||
支付成功后:
|
||||
|
||||
- 不要只依赖支付宝返回页判断支付成功。
|
||||
- 最终支付状态以服务端订单 / 充值记录状态为准。
|
||||
- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。
|
||||
|
||||
## 5. 各业务场景说明
|
||||
|
||||
### 5.1 套餐订单支付
|
||||
|
||||
普通套餐下单后,如果需要支付,前端调用订单支付接口,并传:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay"
|
||||
}
|
||||
```
|
||||
|
||||
支付宝支付响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay",
|
||||
"payment_link": {
|
||||
"payment_no": "...",
|
||||
"qr_link": "...",
|
||||
"copy_link": "...",
|
||||
"pay_expire_at": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端根据 `payment_link` 发起支付宝支付。
|
||||
|
||||
### 5.2 钱包充值
|
||||
|
||||
创建钱包充值单时传:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay"
|
||||
}
|
||||
```
|
||||
|
||||
支付宝场景返回 `payment_link`;微信场景返回 `pay_config`。
|
||||
|
||||
### 5.3 强制充值购包
|
||||
|
||||
创建订单时,如果后端判断需要强制充值,前端可指定:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay"
|
||||
}
|
||||
```
|
||||
|
||||
如果是支付宝强充,响应中会返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"order_type": "recharge",
|
||||
"recharge": {},
|
||||
"payment_link": {}
|
||||
}
|
||||
```
|
||||
|
||||
前端按支付宝支付链接流程处理。
|
||||
|
||||
## 6. 前端展示建议
|
||||
|
||||
支付宝支付页面建议展示:
|
||||
|
||||
- 支付金额
|
||||
- 支付宝二维码或“去支付宝支付”按钮
|
||||
- 支付链接过期倒计时
|
||||
- “我已完成支付”按钮
|
||||
- “重新获取支付链接”按钮
|
||||
|
||||
点击“我已完成支付”时,不直接判定成功,应查询服务端状态。
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
1. 金额单位仍然是 **分**。
|
||||
2. 支付宝支付不需要 `app_type`。
|
||||
3. `pay_config` 只用于微信支付。
|
||||
4. `payment_link` 只用于支付宝支付。
|
||||
5. 支付状态以服务端为准,不以前端跳转结果为准。
|
||||
6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。
|
||||
7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。
|
||||
|
||||
# 支付宝支付链接前端对接说明
|
||||
|
||||
## 1. 对接范围
|
||||
|
||||
本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK,后端会返回已签名的支付宝 WAP 支付链接。
|
||||
|
||||
覆盖场景:
|
||||
|
||||
1. 套餐订单支付
|
||||
2. 钱包充值
|
||||
3. 强制充值购包场景
|
||||
|
||||
微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`。
|
||||
|
||||
具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。
|
||||
|
||||
## 2. 支付方式取值
|
||||
|
||||
前端统一使用 `payment_method` 区分支付方式:
|
||||
|
||||
| 值 | 含义 | 前端处理 |
|
||||
| --- | --- | --- |
|
||||
| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 |
|
||||
| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 |
|
||||
| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 |
|
||||
|
||||
注意事项:
|
||||
|
||||
- `app_type` 只在微信支付时需要。
|
||||
- 支付宝支付时不需要传 `app_type`。
|
||||
- 支付宝支付返回的是支付链接,不是 JSAPI 参数。
|
||||
|
||||
## 3. 前端需要关注的响应字段
|
||||
|
||||
当选择支付宝支付时,接口响应中会返回 `payment_link`:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_link": {
|
||||
"payment_no": "支付单号",
|
||||
"qr_link": "支付宝支付链接,可用于生成二维码",
|
||||
"copy_link": "支付宝支付链接,可复制或直接打开",
|
||||
"pay_expire_at": "支付链接过期时间,RFC3339 格式"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| `payment_no` | 支付单号,问题排查 / 客服查询使用 |
|
||||
| `qr_link` | 前端可用该链接生成二维码 |
|
||||
| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 |
|
||||
| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 |
|
||||
|
||||
## 4. 推荐交互流程
|
||||
|
||||
### 4.1 支付宝支付流程
|
||||
|
||||
前端拿到 `payment_link` 后:
|
||||
|
||||
1. H5 / 浏览器场景:直接跳转 `copy_link`。
|
||||
|
||||
支付成功后:
|
||||
|
||||
- 不要只依赖支付宝返回页判断支付成功。
|
||||
- 最终支付状态以服务端订单 / 充值记录状态为准。
|
||||
- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。
|
||||
|
||||
## 5. 各业务场景说明
|
||||
|
||||
### 5.1 套餐订单支付
|
||||
|
||||
普通套餐下单后,如果需要支付,前端调用订单支付接口,并传:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay"
|
||||
}
|
||||
```
|
||||
|
||||
支付宝支付响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay",
|
||||
"payment_link": {
|
||||
"payment_no": "...",
|
||||
"qr_link": "...",
|
||||
"copy_link": "...",
|
||||
"pay_expire_at": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端根据 `payment_link` 发起支付宝支付。
|
||||
|
||||
### 5.2 钱包充值
|
||||
|
||||
创建钱包充值单时传:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay"
|
||||
}
|
||||
```
|
||||
|
||||
支付宝场景返回 `payment_link`;微信场景返回 `pay_config`。
|
||||
|
||||
### 5.3 强制充值购包
|
||||
|
||||
创建订单时,如果后端判断需要强制充值,前端可指定:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_method": "alipay"
|
||||
}
|
||||
```
|
||||
|
||||
如果是支付宝强充,响应中会返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"order_type": "recharge",
|
||||
"recharge": {},
|
||||
"payment_link": {}
|
||||
}
|
||||
```
|
||||
|
||||
前端按支付宝支付链接流程处理。
|
||||
|
||||
## 6. 前端展示建议
|
||||
|
||||
支付宝支付页面建议展示:
|
||||
|
||||
- 支付金额
|
||||
- 支付宝二维码或“去支付宝支付”按钮
|
||||
- 支付链接过期倒计时
|
||||
- “我已完成支付”按钮
|
||||
- “重新获取支付链接”按钮
|
||||
|
||||
点击“我已完成支付”时,不直接判定成功,应查询服务端状态。
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
1. 金额单位仍然是 **分**。
|
||||
2. 支付宝支付不需要 `app_type`。
|
||||
3. `pay_config` 只用于微信支付。
|
||||
4. `payment_link` 只用于支付宝支付。
|
||||
5. 支付状态以服务端为准,不以前端跳转结果为准。
|
||||
6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。
|
||||
7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"/>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
<script>window.__WX_ENTRY_URL__ = window.location.href.split('#')[0]</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
|
||||
70
main.js
70
main.js
@@ -1,11 +1,55 @@
|
||||
import App from './App'
|
||||
import uviewPlus from 'uview-plus'
|
||||
|
||||
const TAB_BAR_STYLE_NOT_FOUND_ERROR = 'setTabBarStyle:fail not TabBar page'
|
||||
|
||||
const shouldIgnoreTabBarStyleError = (error) => {
|
||||
const message = typeof error === 'string'
|
||||
? error
|
||||
: error?.errMsg || error?.message || ''
|
||||
return message.includes(TAB_BAR_STYLE_NOT_FOUND_ERROR)
|
||||
}
|
||||
|
||||
const patchSafeSetTabBarStyle = () => {
|
||||
if (typeof uni === 'undefined' || typeof uni.setTabBarStyle !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
if (uni.setTabBarStyle.__upSafePatched) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalSetTabBarStyle = uni.setTabBarStyle.bind(uni)
|
||||
const safeSetTabBarStyle = (options = {}) => {
|
||||
try {
|
||||
const result = originalSetTabBarStyle(options)
|
||||
if (result && typeof result.catch === 'function') {
|
||||
return result.catch((error) => {
|
||||
if (shouldIgnoreTabBarStyleError(error)) {
|
||||
return undefined
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (shouldIgnoreTabBarStyleError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
safeSetTabBarStyle.__upSafePatched = true
|
||||
uni.setTabBarStyle = safeSetTabBarStyle
|
||||
}
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
patchSafeSetTabBarStyle()
|
||||
const app = new Vue({
|
||||
...App
|
||||
})
|
||||
@@ -14,11 +58,33 @@ app.$mount()
|
||||
|
||||
// #ifdef VUE3
|
||||
import { createSSRApp } from 'vue'
|
||||
import { assetApi } from '@/api/modules/asset.js'
|
||||
import { getSessionContext } from '@/utils/session-context.js'
|
||||
import { createSilenceGuard, installSilenceGuard } from '@/utils/silence-guard.js'
|
||||
export function createApp() {
|
||||
patchSafeSetTabBarStyle()
|
||||
const app = createSSRApp(App)
|
||||
app.use(uviewPlus)
|
||||
// 沉默期已过期待延期:除绑定手机号、延期支付等页面外全部拦截到延期页
|
||||
installSilenceGuard(app, createSilenceGuard({
|
||||
getSession: getSessionContext,
|
||||
loadPackageHistory: (identifier) => assetApi.getPackageHistory(identifier, 1, 20, { status: 0 })
|
||||
}))
|
||||
// 使用 uview-plus (版本 3.4.0+)
|
||||
app.use(uviewPlus, () => {
|
||||
return {
|
||||
options: {
|
||||
config: {
|
||||
// 默认单位 rpx
|
||||
unit: 'rpx'
|
||||
},
|
||||
props: {
|
||||
// 可以在这里配置组件默认属性
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
// #endif
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name" : "device-voice-h5",
|
||||
"name" : "c-xmot-iot",
|
||||
"appid" : "__UNI__45F0251",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
@@ -17,7 +17,10 @@
|
||||
"delay" : 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {},
|
||||
"modules" : {
|
||||
"Barcode" : {},
|
||||
"Camera" : {}
|
||||
},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
@@ -97,6 +100,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sdkConfigs" : {}
|
||||
"sdkConfigs" : {
|
||||
"maps" : {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
456
openspec/AGENTS.md
Normal file
456
openspec/AGENTS.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: `proposal`, `change`, `spec`
|
||||
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||
- Change: `openspec show <change-id> --json --deltas-only`
|
||||
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create `design.md` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal `design.md` skeleton:
|
||||
```markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||
|
||||
Example for RENAMED:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Login`
|
||||
- TO: `### Requirement: User Authentication`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
```bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
Users MUST provide a second factor during login.
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
```
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
```
|
||||
|
||||
auth/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
```
|
||||
|
||||
notifications/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
25
openspec/changes/add-alipay-payment-link-flow/proposal.md
Normal file
25
openspec/changes/add-alipay-payment-link-flow/proposal.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
The front end currently supports WeChat payment and wallet balance payment, but it does not support the new backend Alipay WAP payment-link flow. This prevents users from completing package purchase and wallet recharge scenarios when Alipay is the selected method.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add Alipay as a supported payment method in package purchase, order list immediate payment, and wallet recharge flows
|
||||
- Handle backend `payment_link` responses for Alipay while preserving existing WeChat `pay_config` handling
|
||||
- Keep wallet balance payment behavior unchanged for package purchase
|
||||
- Refresh payment-related page state from backend status after the user returns from the Alipay payment link flow
|
||||
- Preserve current order creation, strong recharge, validation, toast, and list refresh behavior except where Alipay-specific branching is required
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `alipay-payment-link`: Support backend-provided Alipay payment links across package, order, and wallet payment entry points
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `pages/package-order/package-order.vue`, `pages/order-list/order-list.vue`, `pages/my-wallet/my-wallet.vue`, `utils/payment.js`, `api/modules/wallet.js`
|
||||
- Affected UI flows: package payment method popup, order list immediate payment, wallet recharge popup, wallet recharge order immediate payment
|
||||
- No backend API path changes expected
|
||||
- Existing WeChat and wallet payment behavior must remain available
|
||||
@@ -0,0 +1,58 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Package payment SHALL support Alipay payment links
|
||||
The system SHALL allow users to choose Alipay during package purchase and SHALL handle backend Alipay responses through `payment_link` while preserving the existing WeChat and wallet payment branches.
|
||||
|
||||
#### Scenario: User pays a package order with Alipay
|
||||
- **WHEN** the user selects `alipay` in the package payment popup and the backend returns `payment_method: "alipay"` with `payment_link`
|
||||
- **THEN** the system SHALL open or present the returned Alipay payment link for the user
|
||||
- **AND** the system SHALL NOT require WeChat `pay_config` fields for this branch
|
||||
|
||||
#### Scenario: User pays a package order with wallet balance
|
||||
- **WHEN** the user selects `wallet` and the backend completes the package payment without a third-party redirect
|
||||
- **THEN** the system SHALL preserve the existing wallet payment success flow
|
||||
- **AND** the system SHALL refresh relevant wallet-related page state after success
|
||||
|
||||
#### Scenario: Strong recharge branch returns an Alipay payment link
|
||||
- **WHEN** package order creation enters the strong recharge branch and the backend returns `order_type: "recharge"` with `payment_link`
|
||||
- **THEN** the system SHALL use the Alipay payment-link flow for that recharge branch
|
||||
- **AND** the system SHALL preserve the existing strong recharge messaging and follow-up refresh behavior
|
||||
|
||||
### Requirement: Pending order payment SHALL support Alipay payment links
|
||||
The system SHALL allow users to continue payment for pending package orders from the order list through an Alipay payment link flow.
|
||||
|
||||
#### Scenario: User continues a pending order with Alipay
|
||||
- **WHEN** the user starts payment for a pending order from the order list and the backend returns `payment_method: "alipay"` with `payment_link`
|
||||
- **THEN** the system SHALL open or present the returned payment link
|
||||
- **AND** the system SHALL refresh the order list from backend status after the user completes or retries the flow
|
||||
|
||||
### Requirement: Wallet recharge flows SHALL support Alipay payment links
|
||||
The system SHALL support Alipay both when creating a new wallet recharge order and when retrying payment for a pending recharge order.
|
||||
|
||||
#### Scenario: User creates a new wallet recharge with Alipay
|
||||
- **WHEN** the user confirms a wallet recharge with `payment_method: "alipay"` and the backend returns `payment_link`
|
||||
- **THEN** the system SHALL open or present the returned payment link
|
||||
- **AND** the system SHALL preserve the existing recharge validation rules and amount checks
|
||||
|
||||
#### Scenario: User retries a pending recharge order with Alipay
|
||||
- **WHEN** the user continues payment for a pending recharge order and the backend returns `payment_link`
|
||||
- **THEN** the system SHALL open or present the returned Alipay payment link
|
||||
- **AND** the system SHALL refresh wallet detail and recharge list state from backend data after the user completes or retries the flow
|
||||
|
||||
### Requirement: Payment method branching SHALL distinguish WeChat and Alipay contracts
|
||||
The system SHALL treat WeChat and Alipay payment preparation responses as different contracts and SHALL determine final payment success from backend order or recharge status instead of the redirect result alone.
|
||||
|
||||
#### Scenario: WeChat payment returns pay_config
|
||||
- **WHEN** the backend returns `payment_method: "wechat"` with `pay_config`
|
||||
- **THEN** the system SHALL continue to use the existing WeChat H5 payment invocation flow
|
||||
- **AND** the system SHALL preserve the current `app_type` behavior for WeChat requests
|
||||
|
||||
#### Scenario: Alipay payment returns payment_link
|
||||
- **WHEN** the backend returns `payment_method: "alipay"` with `payment_link`
|
||||
- **THEN** the system SHALL use the returned link fields instead of expecting `pay_config`
|
||||
- **AND** the system SHALL NOT depend on `app_type` for the Alipay branch
|
||||
|
||||
#### Scenario: User returns after Alipay jump
|
||||
- **WHEN** the user comes back to the application after opening the Alipay payment link
|
||||
- **THEN** the system SHALL refresh the relevant order or recharge data from backend APIs
|
||||
- **AND** the system SHALL determine whether the payment succeeded from refreshed backend status
|
||||
29
openspec/changes/add-alipay-payment-link-flow/tasks.md
Normal file
29
openspec/changes/add-alipay-payment-link-flow/tasks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## 1. Shared Payment Branching
|
||||
|
||||
- [x] 1.1 Add shared front-end handling for Alipay `payment_link` responses alongside the existing WeChat `pay_config` flow
|
||||
- [x] 1.2 Ensure Alipay requests do not depend on `app_type` and existing WeChat requests continue to work unchanged
|
||||
|
||||
## 2. Package Purchase Flow
|
||||
|
||||
- [x] 2.1 Add Alipay as a selectable payment method in `pages/package-order/package-order.vue`
|
||||
- [x] 2.2 Handle normal package order payment responses for `wechat`, `wallet`, and `alipay`
|
||||
- [x] 2.3 Handle strong recharge branches that return `payment_link` for Alipay
|
||||
|
||||
## 3. Order List Flow
|
||||
|
||||
- [x] 3.1 Add an order-list payment entry that lets the user continue pending orders with Alipay
|
||||
- [x] 3.2 Refresh order list state from backend data after the user completes or retries the Alipay flow
|
||||
|
||||
## 4. Wallet Flow
|
||||
|
||||
- [x] 4.1 Add Alipay as a selectable payment method in the wallet recharge popup
|
||||
- [x] 4.2 Handle Alipay recharge creation responses in `confirmRecharge`
|
||||
- [x] 4.3 Handle Alipay payment retries for pending recharge orders in `handleRechargePayment`
|
||||
- [x] 4.4 Refresh wallet detail and recharge list state from backend data after the Alipay flow
|
||||
|
||||
## 5. Regression Verification
|
||||
|
||||
- [x] 5.1 Verify WeChat package payment still uses `pay_config`
|
||||
- [x] 5.2 Verify wallet-balance package payment remains unchanged
|
||||
- [x] 5.3 Verify Alipay package payment, order-list payment, wallet recharge, and recharge retry can all open a valid payment link flow
|
||||
- [x] 5.4 Verify payment success is determined by refreshed backend status instead of the link jump result
|
||||
28
openspec/changes/add-h5-popup-candidate-flow/proposal.md
Normal file
28
openspec/changes/add-h5-popup-candidate-flow/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The August iteration adds backend-driven H5 popups. `GET /api/c/v1/popup-candidates` returns at most one candidate (risk-exchange or operation) for the current page and asset; risk-exchange candidates require the user to submit a shipping address through `POST /api/c/v1/risk-exchanges/{asset_id}/address`. Popups are persisted as personal notifications, so the existing notification contracts change: list items gain `popup_snapshot` and two new `type` values, unread count includes popups, and a popup is only dismissed by marking its notification read (`PUT /api/c/v1/notifications/{id}/read`). The current H5 client-side priority re-sort contradicts the new fixed backend ordering and must be removed.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Query `GET /api/c/v1/popup-candidates` once per page entry on `home` (index), `package_purchase` (package-order), and `asset_wallet_recharge` (my-wallet); `asset_detail` is explicitly out of scope
|
||||
- Render the returned candidate (title/body) in a shared `PopupCandidate` component and keep the popup silent when no candidate exists
|
||||
- Map `action_type` with a frontend whitelist only: `package_purchase` -> package-order page, `asset_wallet_recharge` -> my-wallet page; empty value shows no action
|
||||
- Dismiss or act on a popup only by marking the notification read through `PUT /api/c/v1/notifications/{id}/read`
|
||||
- Risk-exchange popups show a shipping address form and submit it with the candidate `asset_id` (never derived from the H5 identifier); success shows the exchange number and status
|
||||
- Treat invisible assets (HTTP 400 / `code=1180`) uniformly as resource-invisible with no UI branching
|
||||
- Keep the backend-fixed notification ordering (remove client-side priority re-sort); honor the unread badge contract; include the notification id in mark-read requests
|
||||
- Never use `read-all` inside the popup display chain
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `h5-popup-candidate`: Define once-per-entry popup candidate query, rendering, whitelist action mapping, and read-mark dismissal
|
||||
- `h5-risk-exchange-address`: Define the risk-exchange shipping address form and idempotent submission
|
||||
|
||||
### Modified Capabilities
|
||||
- `personal-notification-list`: Align the personal notification list/unread contracts with popup notifications
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: new `api/modules/popup.js`, new `components/PopupCandidate.vue`, `pages/index/index.vue`, `pages/package-order/package-order.vue`, `pages/my-wallet/my-wallet.vue`, `pages/notifications/notifications.vue`, `api/modules/notification.js`
|
||||
- Backend contracts: C-end popup/risk-exchange/notification endpoints only; admin endpoints are out of scope
|
||||
@@ -0,0 +1,52 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: H5 SHALL query popup candidates once per page entry
|
||||
The system SHALL call `GET /api/c/v1/popup-candidates` exactly once per page entry with the current page and asset identifier, and SHALL render the returned candidate or stay silent when none exists. The query SHALL NOT be prefetched, polled, or repeated inside one entry.
|
||||
|
||||
#### Scenario: Candidate exists on page entry
|
||||
- **WHEN** the user enters `home`, `package_purchase`, or `asset_wallet_recharge` and the backend returns a candidate
|
||||
- **THEN** the system SHALL show the candidate title/body popup
|
||||
|
||||
#### Scenario: No candidate on page entry
|
||||
- **WHEN** the backend returns an empty candidate
|
||||
- **THEN** the system SHALL show no popup
|
||||
- **AND** the page SHALL continue its normal flow
|
||||
|
||||
#### Scenario: Same notification id within one day
|
||||
- **WHEN** a later page entry returns the same `notification_id` already handled this session
|
||||
- **THEN** the system SHALL NOT re-popup the same notification
|
||||
|
||||
### Requirement: Popup actions SHALL use a frontend whitelist
|
||||
The system SHALL map `action_type` through a frontend whitelist only and SHALL NOT accept URLs or routes from the backend. `package_purchase` routes to the package purchase page, `asset_wallet_recharge` routes to the wallet page, and an empty value shows no action.
|
||||
|
||||
#### Scenario: package_purchase action
|
||||
- **WHEN** the candidate `action_type` is `package_purchase`
|
||||
- **THEN** the popup SHALL navigate to the package purchase page
|
||||
|
||||
#### Scenario: asset_wallet_recharge action
|
||||
- **WHEN** the candidate `action_type` is `asset_wallet_recharge`
|
||||
- **THEN** the popup SHALL navigate to the asset wallet page
|
||||
|
||||
#### Scenario: No action
|
||||
- **WHEN** `action_type` is empty
|
||||
- **THEN** the popup SHALL show no action button
|
||||
|
||||
### Requirement: Popup dismissal SHALL mark the notification read
|
||||
The system SHALL call `PUT /api/c/v1/notifications/{id}/read` with the candidate `notification_id` when the popup is dismissed or when its action is taken. Popup closing and action-taking SHALL NOT use `read-all`.
|
||||
|
||||
#### Scenario: User closes the popup
|
||||
- **WHEN** the user closes the popup
|
||||
- **THEN** the notification SHALL be marked read
|
||||
|
||||
#### Scenario: User takes the popup action
|
||||
- **WHEN** the user taps the action button
|
||||
- **THEN** the notification SHALL be marked read
|
||||
- **AND** the mapped page SHALL open
|
||||
|
||||
### Requirement: Resource-invisible responses SHALL be silent
|
||||
The system SHALL treat HTTP 400 / `code=1180` responses uniformly as resource-invisible and SHALL NOT branch UI, show special prompts, or distinguish asset-missing from not-owned states.
|
||||
|
||||
#### Scenario: Invisible asset on popup query
|
||||
- **WHEN** the popup query returns an invisible-asset error
|
||||
- **THEN** the system SHALL show no popup
|
||||
- **AND** the page SHALL continue its normal flow
|
||||
@@ -0,0 +1,28 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Risk-exchange popup SHALL collect a shipping address
|
||||
The system SHALL present a shipping address form when the candidate `popup_type` is `risk_exchange`, collecting recipient name, recipient phone, and full address, and SHALL submit them to `POST /api/c/v1/risk-exchanges/{asset_id}/address` using the candidate `asset_id` (never derived from the H5 identifier).
|
||||
|
||||
#### Scenario: Valid address submission
|
||||
- **WHEN** the user fills the required fields and submits
|
||||
- **THEN** the risk-exchange request SHALL be submitted with the candidate `asset_id`
|
||||
- **AND** the system SHALL show the returned exchange number and status
|
||||
|
||||
#### Scenario: Invalid or incomplete fields
|
||||
- **WHEN** the user submits empty or invalid fields
|
||||
- **THEN** the system SHALL block submission and prompt for the missing fields
|
||||
|
||||
### Requirement: Address submission SHALL be idempotent
|
||||
The system SHALL NOT provide an edit-address UI. Repeated or concurrent submissions return the first-created exchange order and address without overwriting.
|
||||
|
||||
#### Scenario: Repeated submission
|
||||
- **WHEN** the user submits the address again for the same asset
|
||||
- **THEN** the system SHALL return the first-created exchange order and address
|
||||
|
||||
### Requirement: Risk-exchange failures SHALL keep the user in place
|
||||
The system SHALL keep the address form open on failure and treat invisible-asset failures uniformly without UI branching.
|
||||
|
||||
#### Scenario: Submission failure
|
||||
- **WHEN** the address submission fails
|
||||
- **THEN** the system SHALL keep the form open
|
||||
- **AND** the user SHALL be able to retry
|
||||
@@ -0,0 +1,36 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Personal notification list SHALL preserve backend ordering
|
||||
The system SHALL display personal notifications in the backend-fixed order (created time and notification id descending) and SHALL NOT re-sort items client-side by severity or priority.
|
||||
|
||||
#### Scenario: Backend returns a fixed order
|
||||
- **WHEN** the notification list is loaded
|
||||
- **THEN** the items SHALL be rendered in the returned order
|
||||
|
||||
### Requirement: Notification items SHALL pass through popup fields and types
|
||||
The system SHALL render items with the new `type` values `h5.popup.risk_exchange` and `h5.popup.operation` (category `system`) and SHALL keep `popup_snapshot` available for popup-related actions without treating it as a route.
|
||||
|
||||
#### Scenario: List contains a popup notification
|
||||
- **WHEN** an item has `type` `h5.popup.risk_exchange` or `h5.popup.operation`
|
||||
- **THEN** the item SHALL render as a normal system-category notification
|
||||
|
||||
### Requirement: Unread badge SHALL reflect popup notifications
|
||||
The system SHALL count popup notifications in the unread badge and collapse the badge to `99+` above 99 per the `display_count` contract.
|
||||
|
||||
#### Scenario: Popup notification unread
|
||||
- **WHEN** a popup notification is unread
|
||||
- **THEN** the unread badge SHALL increase accordingly
|
||||
|
||||
### Requirement: Mark-read SHALL include the notification id and be idempotent
|
||||
The system SHALL send the notification `id` in the mark-read request body and SHALL treat success responses idempotently.
|
||||
|
||||
#### Scenario: Mark-read an already-read notification
|
||||
- **WHEN** the notification is already read or owned by another customer
|
||||
- **THEN** the request SHALL still succeed
|
||||
|
||||
### Requirement: read-all SHALL NOT close popups implicitly in the popup chain
|
||||
The system SHALL NOT call `read-all` from any popup display path because it also marks today's popup notifications read.
|
||||
|
||||
#### Scenario: Popup display flow
|
||||
- **WHEN** the popup candidate flow runs
|
||||
- **THEN** the system SHALL NOT call `read-all`
|
||||
36
openspec/changes/add-h5-popup-candidate-flow/tasks.md
Normal file
36
openspec/changes/add-h5-popup-candidate-flow/tasks.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## 1. API Layer
|
||||
|
||||
- [ ] 1.1 Add `popupApi.getCandidate(page, identifier)` for `GET /api/c/v1/popup-candidates`
|
||||
- [ ] 1.2 Add `popupApi.submitRiskExchangeAddress(assetId, params)` for `POST /api/c/v1/risk-exchanges/{asset_id}/address`
|
||||
- [ ] 1.3 Include the notification `id` in the mark-read request body (R3)
|
||||
|
||||
## 2. PopupCandidate Component
|
||||
|
||||
- [ ] 2.1 Query the candidate once per mount and emit `empty` when none exists
|
||||
- [ ] 2.2 Render title/body and hide silently on resource-invisible responses
|
||||
- [ ] 2.3 Map `action_type` through the whitelist and navigate from the mapped page
|
||||
- [ ] 2.4 Mark the notification read on dismiss and on action (R3)
|
||||
- [ ] 2.5 Deduplicate by `notification_id` within a session so same-day repeats do not re-popup
|
||||
- [ ] 2.6 Provide the risk-exchange address form, submission, and result view
|
||||
|
||||
## 3. Page Wiring
|
||||
|
||||
- [ ] 3.1 Wire `home` in `pages/index/index.vue` with priority over the legacy unread-notification popup
|
||||
- [ ] 3.2 Wire `package_purchase` in `pages/package-order/package-order.vue`
|
||||
- [ ] 3.3 Wire `asset_wallet_recharge` in `pages/my-wallet/my-wallet.vue`
|
||||
- [ ] 3.4 Leave `asset_detail` unwired
|
||||
|
||||
## 4. Notification Contract Alignment
|
||||
|
||||
- [ ] 4.1 Remove client-side priority re-sort and rely on the backend ordering
|
||||
- [ ] 4.2 Align the category label map with `approval/expiry/sync/system`
|
||||
- [ ] 4.3 Keep the unread badge consistent with popup counting and `99+` display
|
||||
- [ ] 4.4 Keep `read-all` out of the popup display chain
|
||||
|
||||
## 5. Regression Verification
|
||||
|
||||
- [ ] 5.1 Operation popup with action navigates to the mapped page and marks read
|
||||
- [ ] 5.2 Risk-exchange popup submits the address and shows the exchange number
|
||||
- [ ] 5.3 Repeat entry with the same `notification_id` does not re-popup
|
||||
- [ ] 5.4 Invisible asset shows no popup and no error branch
|
||||
- [ ] 5.5 Notification center renders new `h5.popup.*` types without layout breakage
|
||||
@@ -0,0 +1,19 @@
|
||||
# Change: Retry 19-digit ICCID login with a Luhn check digit
|
||||
|
||||
## Why
|
||||
|
||||
Some IoT cards are entered or scanned as a 19-digit ICCID prefix. The asset-verification endpoint requires the 20-digit ICCID, so the first verification cannot return an asset token even though the identifier can be deterministically completed.
|
||||
|
||||
## What Changes
|
||||
|
||||
- When a login identifier is exactly 19 numeric characters and starts with `89`, call `/api/c/v1/auth/verify-asset` with the entered value first.
|
||||
- If that first request fails or does not return `asset_token`, compute the twentieth digit with the ISO/IEC 7812 Luhn (mod-10) check-digit algorithm and retry verification once using the completed ICCID.
|
||||
- Keep the first failure entirely silent to the customer. Only the retry failure is handled by the existing login error flow.
|
||||
- Persist and use the successful 20-digit ICCID for the authenticated session without mutating the text in the input field during the silent retry.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected capability: `iccid-login-check-digit-retry` (new)
|
||||
- Affected code: `pages/login/login.vue`
|
||||
- Affected API: `POST /api/c/v1/auth/verify-asset`
|
||||
- No backend API, request payload shape, or shared error handling is changed.
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Silent 19-digit ICCID verification retry
|
||||
|
||||
When an asset login identifier matches `^89\\d{17}$`, the H5 client SHALL first verify the entered 19-digit value. If that attempt fails or does not return a non-empty `asset_token`, it SHALL calculate the ISO/IEC 7812 Luhn mod-10 check digit, append it as the twentieth digit, and verify the completed ICCID exactly once.
|
||||
|
||||
#### Scenario: Completed ICCID succeeds
|
||||
|
||||
- **WHEN** a customer enters a 19-digit numeric identifier beginning with `89`
|
||||
- **AND** the initial verification does not provide an asset token
|
||||
- **AND** verification of the Luhn-completed 20-digit ICCID returns an asset token
|
||||
- **THEN** the customer continues through login without seeing an error from the first verification
|
||||
- **AND** the authenticated session uses the 20-digit ICCID
|
||||
|
||||
#### Scenario: Initial verification succeeds
|
||||
|
||||
- **WHEN** a customer enters a 19-digit numeric identifier beginning with `89`
|
||||
- **AND** initial verification returns an asset token
|
||||
- **THEN** the client SHALL not calculate or submit a second identifier
|
||||
|
||||
#### Scenario: Completed ICCID fails
|
||||
|
||||
- **WHEN** the Luhn-completed retry does not return an asset token
|
||||
- **THEN** the client SHALL invoke the existing login failure presentation once using the retry failure
|
||||
|
||||
#### Scenario: Identifier is not a 19-digit ICCID prefix
|
||||
|
||||
- **WHEN** an identifier does not match `^89\\d{17}$`
|
||||
- **AND** verification fails or does not return an asset token
|
||||
- **THEN** the client SHALL not issue a retry
|
||||
- **AND** SHALL retain the existing login failure presentation
|
||||
10
openspec/changes/add-iccid-login-check-digit-retry/tasks.md
Normal file
10
openspec/changes/add-iccid-login-check-digit-retry/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Login fallback
|
||||
|
||||
- [x] 1.1 Add a pure ISO/IEC 7812 Luhn mod-10 check-digit helper for a 19-digit ICCID prefix.
|
||||
- [x] 1.2 Retry asset verification once and silently only when the initial identifier matches `^89\\d{17}$` and the first verification fails or lacks `asset_token`.
|
||||
- [x] 1.3 Persist the completed ICCID only after the retry succeeds; retain the existing visible error path for all final failures and non-matching identifiers.
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 Verify known Luhn vectors plus first-attempt success, fallback success, fallback failure, and non-ICCID failure behavior.
|
||||
- [x] 2.2 Run the H5 production build.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-07
|
||||
87
openspec/changes/add-package-silence-extension/design.md
Normal file
87
openspec/changes/add-package-silence-extension/design.md
Normal file
@@ -0,0 +1,87 @@
|
||||
## Context
|
||||
|
||||
- 后端 8-11 提供的沉默期 C 端能力(接口说明见 `junhong_cmp_fiber/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md` 第 4、6、9 节):
|
||||
- 套餐历史 `GET /api/c/v1/asset/package-history` 每笔套餐带 `silence` 对象,总开关关闭或不适用时不返回。
|
||||
- `/api/c/v1/package-silence`:`GET /eligibility`、`GET /quote`、`POST /orders`、`GET /orders`、`GET /orders/:id`、`GET /records`。资产须属于当前客户。
|
||||
- C 端现状(`iteration/8-11`):
|
||||
- 套餐历史页 `pages/asset-package-history` 通过 `components/PackageHistoryNode.vue` 递归渲染主套餐与加油包,`api/modules/asset.js` 的 `normalizePackageHistoryNode` 用展开运算保留原字段,`silence` 会原样透传。
|
||||
- 接口统一走 `utils/request.js`:`code !== 0` 时 reject 原始响应(含 `code`、`msg`、`data`),`showError: false` 可关闭默认提示。
|
||||
- 支付:套餐购买走 `orderApi.create/pay`,微信用公众号 JSAPI(`wechatH5Pay`),支付宝走 `pages/alipay-payment`(`u-qrcode` 渲染二维码 + 复制链接),返回刷新用 `markPendingPaymentRefresh` / `consumePendingPaymentRefresh`。
|
||||
- 支付方式标签与过滤在 `utils/payment-methods.js`,资产信息返回 `allowed_payment_methods`。
|
||||
- 后端延期下单的支付:`POST /orders` 只返回 `qr_content`,按商户池路由:支付宝为 WAP 支付链接,微信为微信 H5 支付链接(`H5URL`),富友为扫码内容。没有返回 JSAPI 支付参数。
|
||||
- 约束:基于 `iteration/8-11`,只部署测试环境;不改停复机逻辑;不改现有套餐购买流程。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 套餐历史可见沉默期状态与截止日,可延期的套餐有入口。
|
||||
- 客户能自助完成付费延期并看到结果。
|
||||
- 金额、可选天数、新截止日全部以后端为准。
|
||||
|
||||
**Non-Goals:**
|
||||
- 特殊延期、线下登记、批量延期(后台功能)。
|
||||
- 后端改动,包括为延期订单增加微信 JSAPI 支付。
|
||||
- 生产发布。
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 沉默期展示放在 `PackageHistoryNode` 内
|
||||
|
||||
在套餐卡片"到期时间"下方加沉默期区域,`node.silence` 为空时整块不渲染;加油包节点复用同一组件,规则一致。不新开详情页,客户在原页面即可看到状态。状态名直接用后端 `silence_status_name`,前端只按 `silence_status` 选颜色(waiting 蓝、expired 红、ended 灰)。
|
||||
|
||||
备选:在套餐历史页顶部汇总沉默期。拒绝原因:一个资产可能有多笔套餐,按笔展示更清楚。
|
||||
|
||||
### D2 新增接口模块 `api/modules/packageSilence.js`
|
||||
|
||||
与现有模块风格一致,导出 `packageSilenceApi`:`getEligibility`、`quote`、`createOrder`、`getOrders`、`getOrder`、`getRecords`。下单和资格请求 `showError: false`,由页面按错误码映射提示(D6)。订单列表默认带当前资产 `asset_identifier`,只看当前资产的订单。
|
||||
|
||||
### D3 页面结构
|
||||
|
||||
新增三个页面并在 `pages.json` 注册:
|
||||
|
||||
| 页面 | 用途 |
|
||||
|---|---|
|
||||
| `pages/silence-extension/silence-extension` | 资格、选天数、试算、选支付方式、下单 |
|
||||
| `pages/silence-orders/silence-orders` | 我的延期订单列表,点击进入详情(详情用同目录 `detail` 页) |
|
||||
| `pages/silence-records/silence-records` | 某笔套餐的沉默期流水 |
|
||||
|
||||
延期页参数 `package_usage_id`,流水页参数 `package_usage_id`。入口:套餐卡片"延期""沉默期记录";"我的延期订单"放在套餐历史页右上角或延期页底部。
|
||||
|
||||
### D4 天数选择与试算
|
||||
|
||||
优先展示 `options` 档位(每档计费天数、金额、新截止日),默认选第一档;同时提供"自定义天数"输入,输入停止 500ms 后调 `quote`,以最后一次请求结果为准(丢弃过期响应)。前端只校验 `min_days ≤ days ≤ max_days` 和整数,金额一律显示后端值(分转元,两位小数)。
|
||||
|
||||
### D5 下单幂等与支付拉起
|
||||
|
||||
- `request_id` 在客户点击"确认支付"时生成(时间戳 + 随机串),存在页面状态中;同一次提交的网络重试复用,天数或支付方式变化后清空。
|
||||
- 提交中按钮置为 loading,禁止重复提交。
|
||||
- 支付宝:复用 `openAlipayPayment` 跳 `pages/alipay-payment`,二维码与复制链接都用 `qr_content`。
|
||||
- 微信:后端返回的是微信 H5 支付链接或扫码内容,不是 JSAPI 参数。实现时先按"`qr_content` 是 http(s) 链接则 `window.location.href` 跳转,否则用 `u-qrcode` 渲染二维码并提示截图扫码"处理,见 Open Questions。
|
||||
- 拉起前 `markPendingPaymentRefresh('silence-extension')`;延期页与订单详情 `onShow` 时 `consume` 到标记即查询订单详情。
|
||||
|
||||
### D6 错误码映射
|
||||
|
||||
在 `utils/silence-errors.js` 维护 1263~1274 的客户提示文案,1265、1266 用 `data` 中的范围拼接文案;资格接口的 `reason` 也映射为文案(not_expired、no_silence、extension_disabled、no_policy、exceeds_limit、refunded、order_pending、activated、adjust_pending)。未映射的错误码回退为后端 `msg`。
|
||||
|
||||
### D7 测试
|
||||
|
||||
沿用 `tests/october-h5.test.mjs` 的运行方式新增测试:套餐卡片沉默期显示与入口条件、天数校验、`request_id` 复用规则、错误码映射、支付返回刷新。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [微信 H5 支付链接在微信内置浏览器中通常无法直接支付] → C 端主要在公众号内打开。上线测试前需要确认测试环境商户池给延期订单路由的微信渠道;若确实不能在微信内支付,短期只开放支付宝或二维码,长期需后端为延期订单提供 JSAPI 支付(另开后端变更)。
|
||||
- [总开关默认关闭,测试环境看不到沉默期] → 验证前请超级管理员在后台打开总开关(打开前有 4 项检查)。
|
||||
- [支付回跳不可靠] → 不依赖回跳,统一在 `onShow` 查订单详情;订单列表也可手动刷新。
|
||||
- [延期下单不涉及停复机] → 延期只改套餐沉默期截止日;后端在延期生效后是否复机由后端规则决定,测试环境资产已全部在只读表中,不会真实复机。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 在 `iteration/8-11` 上实现并本地 `build:h5`(development 模式,接测试后端)。
|
||||
2. 推送 `iteration/8-11` 部署测试环境(C 端 CI 目前推送未触发构建,需先查原因或手动部署)。
|
||||
3. 测试环境打开沉默期总开关,用测试资产验证:展示、资格、试算、下单、支付、流水。
|
||||
4. 回退:回滚 `iteration/8-11` 对应提交重新部署;后端关闭总开关时前端自动不显示沉默期。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 延期订单的微信支付方式:测试与生产商户池对延期订单走哪种微信渠道?是否需要后端提供 JSAPI 支付?决定前按 D5 的兜底方式实现。
|
||||
- "我的延期订单"入口位置(套餐历史页右上角 / 首页功能卡 / 延期页底部),默认放套餐历史页右上角。
|
||||
31
openspec/changes/add-package-silence-extension/proposal.md
Normal file
31
openspec/changes/add-package-silence-extension/proposal.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
后端 8-11 迭代已上线"实名即生效套餐沉默期":套餐历史返回 `silence` 对象,并提供 H5 自助延期接口(`/api/c/v1/package-silence/*`)。C 端目前没有用上任何沉默期字段和接口,客户看不到沉默期状态,沉默期过期后也无法自助付费延期,只能找运营在后台办理。依据 `junhong_cmp_fiber/docs/2026-11-06-测试环境8-11新功能前端对接与权限现状.md` 第 5.1 节补齐 C 端。
|
||||
|
||||
本变更基于 `iteration/8-11` 分支,只部署测试环境。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 套餐历史每笔套餐展示沉默期:状态、截止时间、沉默期天数、累计延期天数、"调整审批中"标记;`silence` 为空时不显示,保持现有展示。
|
||||
- `silence.extendable = true` 时显示"延期"入口。
|
||||
- 新增自助延期流程:查询延期资格 → 选择或输入延期天数并试算金额与新截止日 → 选择支付方式下单 → 用返回的 `qr_content` 拉起支付或展示二维码 → 支付后刷新状态。
|
||||
- 新增"我的延期订单"列表与详情。
|
||||
- 新增套餐沉默期流水查看。
|
||||
- 沉默期错误码 1263~1274 给出客户能看懂的提示。
|
||||
- 不改现有套餐购买、钱包、实名流程;不改任何停复机相关逻辑(前端本来也不发起停复机)。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `package-silence-extension`:C 端套餐沉默期展示、自助付费延期、延期订单与沉默期流水查看、沉默期错误提示。
|
||||
|
||||
### Modified Capabilities
|
||||
(无。本仓库 `openspec/specs/` 下没有已有规格。)
|
||||
|
||||
## Impact
|
||||
|
||||
- 页面:`pages/asset-package-history`、`components/PackageHistoryNode.vue`;新增延期页、延期订单列表与详情页、沉默期流水页,并在 `pages.json` 注册。
|
||||
- 接口:新增 `api/modules/packageSilence.js`,在 `api/index.js` 导出;`api/modules/asset.js` 的套餐历史归一化保留 `silence` 字段。
|
||||
- 支付:复用 `utils/payment.js` 的支付宝支付页和待刷新标记;微信支付方式取决于后端 `qr_content` 的类型,见 design 待确认事项。
|
||||
- 后端接口(8-11 已提供,不改):`GET /api/c/v1/asset/package-history`,`GET /api/c/v1/package-silence/eligibility`、`/quote`、`/orders`、`/orders/:id`、`/records`,`POST /api/c/v1/package-silence/orders`。
|
||||
- 依赖后端总开关 `package.silence.enabled`:关闭时不返回 `silence`、延期接口返回 1274;测试环境验证前需要打开。
|
||||
@@ -0,0 +1,97 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐历史展示沉默期
|
||||
|
||||
套餐历史中每笔套餐返回 `silence` 对象时,系统 SHALL 在该套餐卡片中展示沉默期信息:状态名称 `silence_status_name`、截止时间 `silence_deadline_at`、沉默期天数 `silence_period_days`、累计延期天数 `silence_extended_days`(大于 0 时)。`silence_adjust_pending = true` 时 SHALL 显示"调整审批中"。`silence` 为空或缺失时 MUST NOT 显示任何沉默期内容,其余展示保持不变。关联加油包节点同样按此规则处理。
|
||||
|
||||
#### Scenario: 待实名套餐
|
||||
- **WHEN** 套餐历史中一笔套餐的 `silence.silence_status = waiting`,截止时间为 2026-12-05 23:59:59
|
||||
- **THEN** 该套餐卡片显示"待实名"(或后端返回的"延期待实名")和截止日期 2026-12-05
|
||||
|
||||
#### Scenario: 总开关关闭
|
||||
- **WHEN** 套餐历史返回的套餐都没有 `silence` 字段
|
||||
- **THEN** 页面与改动前一致,不显示沉默期区域和延期入口
|
||||
|
||||
#### Scenario: 调整审批中
|
||||
- **WHEN** 一笔套餐的 `silence.silence_adjust_pending = true`
|
||||
- **THEN** 沉默期区域显示"调整审批中"
|
||||
|
||||
### Requirement: 延期入口
|
||||
|
||||
系统 SHALL 只在 `silence.extendable = true` 时在该套餐卡片显示"延期"入口;点击后进入该套餐的延期页面,并带上 `package_usage_id`。`extendable` 为 false 或缺失时 MUST NOT 显示入口。
|
||||
|
||||
#### Scenario: 已过期可延期
|
||||
- **WHEN** 一笔套餐 `silence.silence_status = expired` 且 `extendable = true`
|
||||
- **THEN** 卡片显示"延期"入口,点击进入延期页面
|
||||
|
||||
#### Scenario: 不可延期
|
||||
- **WHEN** 一笔套餐 `silence.extendable = false`
|
||||
- **THEN** 卡片不显示"延期"入口
|
||||
|
||||
### Requirement: 延期资格与试算
|
||||
|
||||
进入延期页面时,系统 SHALL 调用 `GET /api/c/v1/package-silence/eligibility?package_usage_id=` 获取资格。`can_extend = false` 时 SHALL 按 `reason` 展示不能办理的原因,MUST NOT 允许下单;`pending_order_no` 非空时 SHALL 提示已有未完成的延期订单,并提供进入该订单详情的入口。可以办理时 SHALL 展示当前截止日期、已过期天数、可选天数范围(`min_days`~`max_days`),并列出 `options` 中每档的计费天数、金额与新截止日期供选择;客户输入自定义天数时 SHALL 调用 `GET /quote` 试算并展示计费天数、金额与新截止日期。金额 MUST 以后端返回为准,前端只传天数、不自行计算金额。输入天数超出 `min_days`~`max_days` 时 MUST 在提交前提示并禁止下单。
|
||||
|
||||
#### Scenario: 选择档位
|
||||
- **WHEN** 资格返回 `options` 含 30 天 / 60 天两档,客户选择 60 天
|
||||
- **THEN** 页面显示 60 天档的金额(分换算为元)与新截止日期,可以下单
|
||||
|
||||
#### Scenario: 输入天数试算
|
||||
- **WHEN** 客户输入 31 天
|
||||
- **THEN** 页面调用试算接口,显示计费天数 60 天、对应金额与新截止日期
|
||||
|
||||
#### Scenario: 已有未完成订单
|
||||
- **WHEN** 资格返回 `pending_order_no` 非空
|
||||
- **THEN** 页面提示已有未完成的延期订单,不能新建,并可进入该订单详情
|
||||
|
||||
#### Scenario: 不能办理
|
||||
- **WHEN** 资格返回 `can_extend = false`、`reason = not_expired`
|
||||
- **THEN** 页面显示"套餐沉默期尚未过期,暂不需要延期",下单按钮不可用
|
||||
|
||||
### Requirement: 自助下单与支付
|
||||
|
||||
客户确认天数与支付方式后,系统 SHALL 调用 `POST /api/c/v1/package-silence/orders`,参数为 `package_usage_id`、`days`、`payment_method`、`request_id`。支付方式 MUST 只在 `wechat`、`alipay` 中,并取当前资产 `allowed_payment_methods` 的交集;交集为空时 SHALL 提示暂不支持在线延期。同一次提交的网络重试 MUST 复用同一个 `request_id`,客户重新确认(天数或支付方式变化)时 SHALL 生成新的 `request_id`。提交期间 MUST 防止重复点击。下单成功后 SHALL 用返回的 `qr_content` 拉起支付:支付宝走现有支付宝支付页(二维码 + 复制链接);微信按 design 中确定的方式处理。拉起支付后 SHALL 记录待刷新标记,客户返回时刷新订单状态。
|
||||
|
||||
#### Scenario: 支付宝下单
|
||||
- **WHEN** 客户选择 30 天、支付宝支付并确认
|
||||
- **THEN** 系统下单后进入支付宝支付页,展示 `qr_content` 二维码与复制链接
|
||||
|
||||
#### Scenario: 重复点击
|
||||
- **WHEN** 客户在下单请求返回前再次点击确认
|
||||
- **THEN** 系统不发起第二次下单请求
|
||||
|
||||
#### Scenario: 无可用支付方式
|
||||
- **WHEN** 资产 `allowed_payment_methods` 只有 `wallet`
|
||||
- **THEN** 延期页面提示暂不支持在线延期,不显示下单按钮
|
||||
|
||||
### Requirement: 支付结果与订单查看
|
||||
|
||||
系统 SHALL 提供"我的延期订单"列表(`GET /api/c/v1/package-silence/orders`,可按支付时间 `paid_start`、`paid_end` 筛选,分页加载)与详情(`GET /orders/:id`)。详情 SHALL 展示订单号、套餐、天数(输入天数与计费天数)、金额、状态名称、支付方式、下单前截止时间、预计新截止时间、支付时间、延期生效时间;状态为 `pending_payment` 且返回 `qr_content` 时 SHALL 提供"继续支付"。从支付返回后 SHALL 重新查询订单详情;状态为 `completed` 时提示延期成功并刷新套餐历史。
|
||||
|
||||
#### Scenario: 支付成功返回
|
||||
- **WHEN** 客户支付完成返回,订单状态为 `completed`
|
||||
- **THEN** 页面提示延期成功,套餐历史中该套餐显示新的截止日期
|
||||
|
||||
#### Scenario: 继续支付
|
||||
- **WHEN** 客户打开一笔 `pending_payment` 的延期订单,详情带 `qr_content`
|
||||
- **THEN** 页面显示"继续支付",点击后按原支付方式拉起支付
|
||||
|
||||
### Requirement: 沉默期流水
|
||||
|
||||
系统 SHALL 在有 `silence` 的套餐上提供"沉默期记录"入口,调用 `GET /api/c/v1/package-silence/records?package_usage_id=` 按时间展示该套餐的沉默期变化记录;无记录时显示空状态。
|
||||
|
||||
#### Scenario: 查看流水
|
||||
- **WHEN** 客户在一笔已延期过的套餐上打开沉默期记录
|
||||
- **THEN** 页面按时间列出起算、延期等记录
|
||||
|
||||
### Requirement: 沉默期错误提示
|
||||
|
||||
沉默期接口返回错误码 1263~1274 时,系统 SHALL 显示面向客户的中文提示,MUST NOT 直接展示内部错误名称。至少包括:1263 套餐当前不需要延期、1264 该套餐不支持付费延期请联系客服、1265 超过可延期期限、1266 延期天数不在允许范围(带 `min_days`~`max_days`)、1267 免费延期次数已用完、1270 暂未开放延期请联系客服、1271 已有未完成的延期订单、1274 沉默期功能未开启。其他错误码沿用通用错误提示。
|
||||
|
||||
#### Scenario: 天数超范围
|
||||
- **WHEN** 下单返回 1266,`data.min_days = 30`、`data.max_days = 180`
|
||||
- **THEN** 页面提示"延期天数需在 30~180 天之间"
|
||||
|
||||
#### Scenario: 功能未开启
|
||||
- **WHEN** 资格接口返回 1274
|
||||
- **THEN** 延期页面提示"延期功能暂未开放",不显示下单按钮
|
||||
38
openspec/changes/add-package-silence-extension/tasks.md
Normal file
38
openspec/changes/add-package-silence-extension/tasks.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## 1. 接口与工具
|
||||
|
||||
- [x] 1.1 新增 `api/modules/packageSilence.js`(eligibility、quote、createOrder、getOrders、getOrder、getRecords),在 `api/index.js` 导出
|
||||
- [x] 1.2 确认 `normalizeAssetPackageHistory` 保留 `silence` 对象(含加油包子节点),补充布尔字段 `extendable`、`silence_adjust_pending` 归一化
|
||||
- [x] 1.3 新增 `utils/silence.js`(错误码与延期纯函数,代替原计划的 silence-errors.js):错误码 1263~1274 与资格 `reason` 的客户提示文案,1265/1266 拼接范围
|
||||
- [x] 1.4 新增延期支付方式过滤:`allowed_payment_methods` 与 `wechat`、`alipay` 取交集
|
||||
|
||||
## 2. 套餐历史展示
|
||||
|
||||
- [x] 2.1 `PackageHistoryNode.vue` 增加沉默期区域:状态名、截止日期、沉默期天数、累计延期天数、"调整审批中";`silence` 为空不渲染
|
||||
- [x] 2.2 `extendable = true` 时显示"延期"入口,跳转延期页并带 `package_usage_id`
|
||||
- [x] 2.3 有 `silence` 的套餐显示"沉默期记录"入口
|
||||
- [x] 2.4 套餐历史页增加"我的延期订单"入口;从延期成功返回时刷新列表
|
||||
|
||||
## 3. 延期页
|
||||
|
||||
- [x] 3.1 新建 `pages/silence-extension/silence-extension` 并注册 `pages.json`
|
||||
- [x] 3.2 加载资格:不能办理时展示原因;有 `pending_order_no` 时提示并提供订单详情入口;1274 显示功能未开放
|
||||
- [x] 3.3 展示当前截止日期、已过期天数、`options` 档位(计费天数、金额、新截止日),默认选第一档
|
||||
- [x] 3.4 自定义天数输入:范围与整数校验,防抖调用试算,丢弃过期响应
|
||||
- [x] 3.5 支付方式选择;无可用方式时提示暂不支持在线延期
|
||||
- [x] 3.6 下单:生成并复用 `request_id`,提交中防重复,错误码按 1.3 提示
|
||||
- [x] 3.7 拉起支付:支付宝走现有支付宝支付页;微信按 design D5(链接跳转或二维码);拉起前写待刷新标记(微信:微信外为链接直接跳转,其余进入延期支付页展示二维码与复制链接)
|
||||
- [x] 3.8 `onShow` 消费待刷新标记后查询订单详情,`completed` 时提示延期成功
|
||||
|
||||
## 4. 延期订单与流水
|
||||
|
||||
- [ ] 4.1 新建延期订单列表页(分页、按支付时间筛选、默认当前资产)(已做分页与默认当前资产;按支付时间筛选未做)
|
||||
- [x] 4.2 新建延期订单详情页:订单号、套餐、输入/计费天数、金额、状态、支付方式、截止时间变化、支付与生效时间;`pending_payment` 且有 `qr_content` 时"继续支付"
|
||||
- [x] 4.3 新建沉默期流水页:按时间展示 `event_name`、截止时间变化、天数,空状态
|
||||
|
||||
## 5. 测试与验证
|
||||
|
||||
- [x] 5.1 在 `tests/` 增加用例:沉默期显示与入口条件、天数校验、`request_id` 复用、错误码映射、支付返回刷新(tests/silence.test.mjs 覆盖天数校验、request_id 复用、错误码映射、支付方式过滤;页面级的显示与入口条件、支付返回刷新未写自动化用例;既有 october-h5 38 项通过)
|
||||
- [x] 5.2 本地 `build:h5`(development 模式)通过,确认接口指向测试后端
|
||||
- [ ] 5.3 确认延期订单微信支付方式(design Open Questions),按结论调整 3.7(测试库微信商户池为 wechat_v2 直连,返回 H5 支付链接,公众号内需实测)
|
||||
- [ ] 5.4 测试环境打开沉默期总开关后,用测试资产走通:展示 → 资格 → 试算 → 下单 → 支付 → 延期生效 → 流水
|
||||
- [ ] 5.5 推送 `iteration/8-11` 部署测试环境(先排查 C 端 CI 未触发构建的原因)
|
||||
26
openspec/changes/add-payment-submit-guard/proposal.md
Normal file
26
openspec/changes/add-payment-submit-guard/proposal.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
The wallet recharge confirmation, package payment confirmation, order list immediate payment, and wallet recharge order immediate payment flows can be triggered repeatedly when users tap quickly or when the page is slow to respond. This can cause duplicate order creation, duplicate payment preparation, repeated payment invocation, and inconsistent UI state.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a front-end submit guard for the wallet recharge confirmation flow in `pages/my-wallet/my-wallet.vue`
|
||||
- Add a front-end submit guard for the package payment confirmation flow in `pages/package-order/package-order.vue`
|
||||
- Add a front-end submit guard for the order list immediate payment flow in `pages/order-list/order-list.vue`
|
||||
- Add a front-end submit guard for the wallet recharge order immediate payment flow in `pages/my-wallet/my-wallet.vue`
|
||||
- Standardize button behavior during submission so repeated taps are ignored until the current flow ends
|
||||
- Require submit state recovery for success, failure, cancel, validation failure, list refresh, and modal re-entry paths
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `payment-submit-guard`: Prevent duplicate submission in recharge and payment entry points across wallet, package order, and order list flows
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `pages/my-wallet/my-wallet.vue`, `pages/package-order/package-order.vue`, `pages/order-list/order-list.vue`
|
||||
- Affected UI flows: wallet recharge modal, wallet recharge order list immediate payment, package payment method modal, order list immediate payment
|
||||
- No backend API changes
|
||||
- No payment business rule changes
|
||||
@@ -0,0 +1,81 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wallet recharge confirmation SHALL prevent duplicate submission
|
||||
The system SHALL prevent repeated taps on the wallet recharge confirmation button from starting more than one recharge submission flow at the same time.
|
||||
|
||||
#### Scenario: User taps confirm recharge repeatedly during submission
|
||||
- **WHEN** the user taps the wallet recharge confirmation button multiple times before the current `confirmRecharge` flow completes
|
||||
- **THEN** the system SHALL execute only the first submission flow
|
||||
- **AND** the system SHALL ignore subsequent taps until the current flow ends
|
||||
|
||||
#### Scenario: Wallet recharge confirmation enters submitting state
|
||||
- **WHEN** the first valid wallet recharge confirmation tap is accepted
|
||||
- **THEN** the system SHALL mark the wallet recharge confirmation flow as submitting immediately
|
||||
- **AND** the system SHALL present the confirmation button as non-repeatable during that state
|
||||
|
||||
#### Scenario: Wallet recharge confirmation recovers after terminal outcome
|
||||
- **WHEN** the wallet recharge flow ends because of success, failure, cancellation, local validation failure, or modal re-entry
|
||||
- **THEN** the system SHALL clear the submitting state
|
||||
- **AND** the user SHALL be able to start a new recharge attempt
|
||||
|
||||
### Requirement: Package payment confirmation SHALL prevent duplicate submission
|
||||
The system SHALL prevent repeated taps on the package payment confirmation button from starting more than one package payment submission flow at the same time.
|
||||
|
||||
#### Scenario: User taps confirm payment repeatedly during submission
|
||||
- **WHEN** the user taps the package payment confirmation button multiple times before the current `confirmPay` flow completes
|
||||
- **THEN** the system SHALL execute only the first submission flow
|
||||
- **AND** the system SHALL ignore subsequent taps until the current flow ends
|
||||
|
||||
#### Scenario: Package payment confirmation enters submitting state
|
||||
- **WHEN** the first valid package payment confirmation tap is accepted
|
||||
- **THEN** the system SHALL mark the package payment confirmation flow as submitting immediately
|
||||
- **AND** the system SHALL present the confirmation button as non-repeatable during that state
|
||||
|
||||
#### Scenario: Package payment confirmation recovers after terminal outcome
|
||||
- **WHEN** the package payment flow ends because of success, failure, cancellation, validation failure, strong recharge prompt completion, or modal re-entry
|
||||
- **THEN** the system SHALL clear the submitting state
|
||||
- **AND** the user SHALL be able to start a new payment attempt
|
||||
|
||||
### Requirement: Order list immediate payment SHALL prevent duplicate submission
|
||||
The system SHALL prevent repeated taps on an order list item's immediate payment button from starting more than one payment submission flow for the current order at the same time.
|
||||
|
||||
#### Scenario: User taps order list immediate payment repeatedly during submission
|
||||
- **WHEN** the user taps the immediate payment button multiple times before the current `handleOrderPayment` flow completes
|
||||
- **THEN** the system SHALL execute only the first submission flow
|
||||
- **AND** the system SHALL ignore subsequent taps until the current flow ends
|
||||
|
||||
#### Scenario: Order list immediate payment enters submitting state
|
||||
- **WHEN** the first valid order list immediate payment tap is accepted
|
||||
- **THEN** the system SHALL mark the current order payment flow as submitting immediately
|
||||
- **AND** the system SHALL present the triggered immediate payment button as non-repeatable during that state
|
||||
|
||||
#### Scenario: Order list immediate payment recovers after terminal outcome
|
||||
- **WHEN** the order list payment flow ends because of success, failure, cancellation, list refresh, or page re-entry
|
||||
- **THEN** the system SHALL clear the submitting state
|
||||
- **AND** the user SHALL be able to start a new payment attempt if the order remains payable
|
||||
|
||||
### Requirement: Wallet recharge order immediate payment SHALL prevent duplicate submission
|
||||
The system SHALL prevent repeated taps on a wallet recharge order item's immediate payment button from starting more than one recharge order payment flow for the current recharge order at the same time.
|
||||
|
||||
#### Scenario: User taps wallet recharge order immediate payment repeatedly during submission
|
||||
- **WHEN** the user taps the immediate payment button multiple times before the current `handleRechargePayment` flow completes
|
||||
- **THEN** the system SHALL execute only the first submission flow
|
||||
- **AND** the system SHALL ignore subsequent taps until the current flow ends
|
||||
|
||||
#### Scenario: Wallet recharge order immediate payment enters submitting state
|
||||
- **WHEN** the first valid wallet recharge order immediate payment tap is accepted
|
||||
- **THEN** the system SHALL mark the current recharge order payment flow as submitting immediately
|
||||
- **AND** the system SHALL present the triggered immediate payment button as non-repeatable during that state
|
||||
|
||||
#### Scenario: Wallet recharge order immediate payment recovers after terminal outcome
|
||||
- **WHEN** the wallet recharge order payment flow ends because of success, failure, cancellation, list refresh, or page re-entry
|
||||
- **THEN** the system SHALL clear the submitting state
|
||||
- **AND** the user SHALL be able to start a new payment attempt if the recharge order remains payable
|
||||
|
||||
### Requirement: Submit guards SHALL NOT change existing payment business rules
|
||||
The system SHALL preserve the current recharge validation, recharge order payment, order list payment, order creation, strong recharge decision, wallet payment, WeChat payment, toast, modal, and balance refresh behavior while adding submit guards.
|
||||
|
||||
#### Scenario: Existing business validation remains unchanged
|
||||
- **WHEN** the user submits with an invalid recharge amount or reaches an existing payment business rule branch
|
||||
- **THEN** the system SHALL keep the current validation and messaging behavior
|
||||
- **AND** the duplicate submission guard SHALL only affect repeated-trigger protection
|
||||
31
openspec/changes/add-payment-submit-guard/tasks.md
Normal file
31
openspec/changes/add-payment-submit-guard/tasks.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## 1. Wallet Recharge Confirmation Submit Guard
|
||||
|
||||
- [x] 1.1 Add a dedicated submitting state for the wallet recharge confirmation flow
|
||||
- [x] 1.2 Ignore repeated taps while `confirmRecharge` is already running
|
||||
- [x] 1.3 Bind the confirmation button state to the submitting state and restore it on every exit path
|
||||
|
||||
## 2. Package Payment Confirmation Submit Guard
|
||||
|
||||
- [x] 2.1 Add a dedicated submitting state for the package payment confirmation flow
|
||||
- [x] 2.2 Ignore repeated taps while `confirmPay` is already running
|
||||
- [x] 2.3 Bind the confirmation button state to the submitting state and restore it on every exit path
|
||||
|
||||
## 3. Order List Immediate Payment Submit Guard
|
||||
|
||||
- [x] 3.1 Add a dedicated submitting state for order list immediate payment
|
||||
- [x] 3.2 Ignore repeated taps while `handleOrderPayment` is already running for the current payment flow
|
||||
- [x] 3.3 Bind the order list immediate payment button state to the submitting state and restore it on every exit path
|
||||
|
||||
## 4. Wallet Recharge Order Immediate Payment Submit Guard
|
||||
|
||||
- [x] 4.1 Add a dedicated submitting state for wallet recharge order immediate payment
|
||||
- [x] 4.2 Ignore repeated taps while `handleRechargePayment` is already running for the current payment flow
|
||||
- [x] 4.3 Bind the recharge order immediate payment button state to the submitting state and restore it on every exit path
|
||||
|
||||
## 5. Regression Verification
|
||||
|
||||
- [x] 5.1 Verify repeated taps only trigger one effective submit in the wallet recharge modal
|
||||
- [x] 5.2 Verify repeated taps only trigger one effective submit in the package payment modal
|
||||
- [x] 5.3 Verify repeated taps only trigger one effective submit in the order list immediate payment flow
|
||||
- [x] 5.4 Verify repeated taps only trigger one effective submit in the wallet recharge order immediate payment flow
|
||||
- [x] 5.5 Verify users can retry after validation failure, payment cancel, payment failure, list refresh, and modal reopen
|
||||
19
openspec/changes/add-personal-notifications/proposal.md
Normal file
19
openspec/changes/add-personal-notifications/proposal.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Change: Add personal customer notifications
|
||||
|
||||
## Why
|
||||
|
||||
个人客户缺少统一的站内通知入口,无法查看业务通知、获取未读数量或管理通知已读状态。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 增加个人客户通知列表查询接口,支持分页和固定倒序排列。
|
||||
- 增加单条通知已读和全部通知已读接口。
|
||||
- 增加个人客户通知未读数查询接口。
|
||||
- 限制通知数据仅返回当前认证客户可见且未过期的业务通知。
|
||||
- 统一返回通知内容、关联资源、通知级别、已读状态及时间信息。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `personal-notifications`
|
||||
- Affected code: 个人客户通知 API、通知列表入口、未读数展示和已读状态处理
|
||||
- No breaking changes to existing APIs
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Personal customers SHALL be able to query notifications
|
||||
|
||||
The system SHALL provide `GET /api/c/v1/notifications` for authenticated personal customers. The endpoint SHALL return only visible, unexpired business notifications for the current customer, ordered by creation time descending and notification ID descending.
|
||||
|
||||
#### Scenario: Query the first notification page
|
||||
- **WHEN** an authenticated customer requests the notification list without pagination parameters
|
||||
- **THEN** the system SHALL return page 1 with up to 20 notifications
|
||||
- **AND** the response SHALL include `items`, `page`, `size`, and `total`
|
||||
|
||||
#### Scenario: Apply valid pagination
|
||||
- **WHEN** the request includes `page` from 1 to 10000 and `page_size` from 1 to 50
|
||||
- **THEN** the system SHALL return the requested page and page size
|
||||
|
||||
#### Scenario: Reject invalid pagination
|
||||
- **WHEN** `page` or `page_size` is outside its allowed range
|
||||
- **THEN** the system SHALL return a parameter validation error
|
||||
|
||||
### Requirement: Notification items SHALL expose client-readable status and metadata
|
||||
|
||||
Each notification item SHALL include `id`, `title`, `body`, `type`, `category`, `severity`, `ref_type`, `ref_id`, `ref_key`, `is_read`, `created_at`, and nullable `read_at`.
|
||||
|
||||
#### Scenario: Return an unread notification
|
||||
- **WHEN** a visible notification has not been read by the current customer
|
||||
- **THEN** the system SHALL return `is_read: false` and `read_at: null`
|
||||
|
||||
### Requirement: Personal customers SHALL be able to mark one notification as read
|
||||
|
||||
The system SHALL provide `PUT /api/c/v1/notifications/{id}/read` for authenticated personal customers. The operation SHALL be idempotent and SHALL only update a notification visible to the current customer.
|
||||
|
||||
#### Scenario: Mark a visible unread notification
|
||||
- **WHEN** the customer marks a visible unread notification as read
|
||||
- **THEN** the system SHALL set its read state and return `{ "success": true }`
|
||||
|
||||
#### Scenario: Mark an unavailable or already-read notification
|
||||
- **WHEN** the notification does not exist, belongs to another customer, or is already read
|
||||
- **THEN** the system SHALL return `{ "success": true }` without exposing ownership information
|
||||
|
||||
### Requirement: Personal customers SHALL be able to mark all notifications as read
|
||||
|
||||
The system SHALL provide `PUT /api/c/v1/notifications/read-all` to mark all visible, unexpired, unread business notifications for the current customer as read.
|
||||
|
||||
#### Scenario: Mark all unread notifications
|
||||
- **WHEN** the customer calls the mark-all-read endpoint
|
||||
- **THEN** the system SHALL return the number of notifications actually updated in `updated_count`
|
||||
- **AND** repeated calls SHALL remain successful and return zero when nothing needs updating
|
||||
|
||||
### Requirement: Personal customers SHALL be able to query unread count
|
||||
|
||||
The system SHALL provide `GET /api/c/v1/notifications/unread-count`. The count SHALL include only visible, unexpired business notifications and SHALL exclude platform sync and system operations notifications.
|
||||
|
||||
#### Scenario: Return unread count and badge text
|
||||
- **WHEN** an authenticated customer queries the unread count
|
||||
- **THEN** the system SHALL return numeric `count` and string `display_count`
|
||||
- **AND** `display_count` SHALL be `99+` when `count` exceeds 99
|
||||
26
openspec/changes/add-personal-notifications/tasks.md
Normal file
26
openspec/changes/add-personal-notifications/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## 1. API and Data Contract
|
||||
|
||||
- [x] 1.1 Define the client-side personal notification item and paginated list contract
|
||||
- [x] 1.2 Define the client-side unread count and read-operation contracts
|
||||
- [x] 1.3 Add authenticated notification API wrappers
|
||||
|
||||
## 2. Notification Behavior
|
||||
|
||||
- [ ] 2.1 Return only current customer's visible, unexpired business notifications
|
||||
- [ ] 2.2 Apply creation-time and notification-ID descending ordering
|
||||
- [ ] 2.3 Support idempotent single-read and read-all operations
|
||||
- [ ] 2.4 Exclude platform sync and system operations notifications from unread count
|
||||
|
||||
## 3. Client Entry
|
||||
|
||||
- [x] 3.1 Add the personal notification list entry and pagination handling
|
||||
- [x] 3.2 Add unread-count display and refresh behavior
|
||||
- [x] 3.3 Mark notifications read from list actions and support mark-all-read
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 Test authentication, pagination limits, ordering, and visibility isolation
|
||||
- [ ] 4.2 Test idempotent read behavior and unread-count updates
|
||||
- [x] 4.3 Verify the H5 build and unified success/error response handling
|
||||
|
||||
> Note: The repository contains the H5 client only. Tasks 2.1-2.4 and 4.1-4.2 require implementation and verification in the backend service repository.
|
||||
@@ -0,0 +1,49 @@
|
||||
## Context
|
||||
|
||||
The current device exchange popup stores recipient name, phone, and address in three flat fields, with `recipient_address` being a single string. The shipping submit API currently accepts only `recipient_name`, `recipient_phone`, and `recipient_address`, so the frontend must remain compatible unless the backend contract changes later.
|
||||
|
||||
The homepage already loads `/api/c/v1/asset/info` through `assetApi.getInfo(identifier)`. The UI shows package name and expiry date in the top summary card, but the request now also returns `data.iccid`, which should be exposed without adding a second asset query.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals:
|
||||
- Add a structured province/city/district selector using `element-china-area-data`
|
||||
- Keep a separate detailed-address field for device exchange shipping info
|
||||
- Allow users to paste one shipping-info string and prefill recipient name, phone, and address fields
|
||||
- Preserve compatibility with the existing exchange shipping submit endpoint
|
||||
- Show `ICCID` above the package name in the homepage top summary card
|
||||
|
||||
- Non-Goals:
|
||||
- Redesign the backend exchange shipping API
|
||||
- Add OCR or image-based recognition for shipping labels
|
||||
- Normalize all historical address data outside the device exchange popup
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: Use `element-china-area-data` as the source for province/city/district options.
|
||||
Why: The request explicitly requires that dataset, and a static local area dataset avoids introducing extra network dependencies.
|
||||
|
||||
- Decision: Keep `/api/c/v1/exchange/{id}/shipping-info` unchanged and compose `recipient_address` on the client.
|
||||
Why: The existing API already accepts one address string, so the frontend can implement the new UX without blocking on backend changes.
|
||||
|
||||
- Decision: Recognize pasted shipping text with best-effort client-side parsing for mainland China phone numbers and common copied-address formats.
|
||||
Why: Users often copy a single line or multi-line string from chat or e-commerce apps, and partial automation reduces manual entry while still allowing correction.
|
||||
|
||||
- Decision: When reopening existing saved shipping info, prefill split fields with best-effort parsing and preserve the original address content if a full region split cannot be derived.
|
||||
Why: Existing records are already stored as a combined string, so the UI needs a no-data-loss compatibility path during the transition.
|
||||
|
||||
- Decision: Use `data.iccid` from the existing asset info response as the homepage summary source of truth.
|
||||
Why: The user explicitly named that field and endpoint, and it avoids mixing multiple ICCID sources in the header card.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Pasted shipping strings vary in format, so recognition must remain best-effort and allow manual correction before submission.
|
||||
- Some previously saved combined addresses may not map cleanly back to province/city/district selections, so fallback behavior must preserve the original text.
|
||||
- Adding a new area dataset dependency increases bundle size slightly, but it keeps address selection deterministic and local.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add the `element-china-area-data` dependency and wire it into the device exchange popup.
|
||||
2. Introduce split address state, pasted-text parsing, and composed submit payload handling.
|
||||
3. Update homepage asset state to read `data.iccid` and render it above package name.
|
||||
4. Verify that existing exchange records remain editable without losing previously saved address content.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The device exchange popup currently collects the shipping address as a single free-text field. This makes Chinese address entry error-prone, does not support selecting province/city/district from a standard area dataset, and forces users to manually rewrite recipient details even when they already copied a full shipping string from another app.
|
||||
|
||||
The homepage top summary card already loads asset information from `/api/c/v1/asset/info`, but it does not surface the `ICCID` value even though the response now provides `data.iccid`. Users need that value visible above the package name on the homepage.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Replace the single shipping-address input in `pages/device-exchange/device-exchange.vue` with province/city/district selection backed by `element-china-area-data` plus a separate detailed-address input
|
||||
- Add a pasteable shipping-info input so the page can recognize and prefill recipient name, phone number, and address from one copied string
|
||||
- Keep the existing exchange shipping submission API contract and assemble the selected region text together with the detailed address into `recipient_address`
|
||||
- Add an `ICCID` line above the package name in the homepage top summary card
|
||||
- Use `data.iccid` from `/api/c/v1/asset/info?identifier=...` as the homepage `ICCID` source of truth
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `device-exchange-shipping-entry`: Define structured shipping entry, pasted shipping-text recognition, and payload composition for the device exchange popup
|
||||
- `index-asset-summary`: Define homepage summary display rules for `ICCID`
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `pages/device-exchange/device-exchange.vue`, `pages/index/index.vue`, `components/UserInfoCard.vue`, related address parsing helpers if introduced
|
||||
- Affected dependencies: `element-china-area-data`
|
||||
- Affected API usage: `/api/c/v1/exchange/{id}/shipping-info`, `/api/c/v1/asset/info`
|
||||
- No backend API contract change is required if the frontend continues submitting a composed `recipient_address` string
|
||||
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Device exchange shipping entry SHALL collect a structured recipient address
|
||||
The system SHALL collect recipient name, recipient phone number, a province/city/district selection sourced from `element-china-area-data`, and a separate detailed address when the user fills shipping information for a pending device exchange.
|
||||
|
||||
#### Scenario: User opens the shipping popup for a pending exchange
|
||||
- **WHEN** the device exchange record is in the state that allows filling shipping information
|
||||
- **AND** the user opens the shipping popup
|
||||
- **THEN** the popup SHALL show editable fields for recipient name, recipient phone number, province/city/district, and detailed address
|
||||
|
||||
#### Scenario: User attempts to submit without a complete address
|
||||
- **WHEN** the user submits the shipping popup without selecting province/city/district or without entering a detailed address
|
||||
- **THEN** the system SHALL block submission
|
||||
- **AND** the system SHALL tell the user that the shipping address is incomplete
|
||||
|
||||
### Requirement: Device exchange shipping entry SHALL support pasted shipping-text recognition
|
||||
The system SHALL provide a way for the user to paste one shipping-info string and SHALL attempt to recognize recipient name, mainland China phone number, and address content from that string into editable form fields.
|
||||
|
||||
#### Scenario: Pasted shipping text contains recognizable recipient information
|
||||
- **WHEN** the user pastes a shipping-info string containing a recipient name, an 11-digit mainland China phone number, and address content
|
||||
- **THEN** the system SHALL prefill the recognized recipient name and phone number
|
||||
- **AND** the system SHALL prefill the recognized address content into the structured address form as far as it can infer
|
||||
|
||||
#### Scenario: Pasted shipping text is only partially recognizable
|
||||
- **WHEN** the user pastes a shipping-info string but the system cannot confidently extract every required field
|
||||
- **THEN** the system SHALL preserve the fields it can recognize
|
||||
- **AND** the user SHALL still be able to manually complete or correct the remaining fields before submission
|
||||
|
||||
### Requirement: Device exchange shipping submission SHALL preserve the existing API contract
|
||||
The system SHALL continue submitting `recipient_name`, `recipient_phone`, and `recipient_address` to `/api/c/v1/exchange/{id}/shipping-info`, and SHALL compose `recipient_address` from the selected province/city/district text together with the detailed address.
|
||||
|
||||
#### Scenario: User submits valid shipping information
|
||||
- **WHEN** the user submits the popup with a valid recipient name, a valid mainland China phone number, a selected province/city/district, and a detailed address
|
||||
- **THEN** the system SHALL call `/api/c/v1/exchange/{id}/shipping-info`
|
||||
- **AND** the payload SHALL keep `recipient_name` and `recipient_phone`
|
||||
- **AND** the payload SHALL send a composed `recipient_address` string containing the selected region text and the detailed address
|
||||
|
||||
### Requirement: Existing exchange shipping information SHALL remain editable without data loss
|
||||
The system SHALL preserve previously saved shipping information when reopening the popup and SHALL support best-effort conversion from a previously saved combined address string into the new structured address fields.
|
||||
|
||||
#### Scenario: Existing saved address can be split into region and detail
|
||||
- **WHEN** the popup opens for an exchange record that already has a saved `recipient_address`
|
||||
- **AND** the saved address can be matched to province/city/district data
|
||||
- **THEN** the system SHALL prefill the region selection and the detailed address
|
||||
|
||||
#### Scenario: Existing saved address cannot be fully split
|
||||
- **WHEN** the popup opens for an exchange record that already has a saved `recipient_address`
|
||||
- **AND** the saved address cannot be confidently split into province/city/district plus detailed address
|
||||
- **THEN** the system SHALL preserve the original saved address content for manual correction
|
||||
- **AND** the user SHALL still be able to complete a valid structured address before resubmitting
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Homepage top summary SHALL display ICCID above the package name
|
||||
The system SHALL display an `ICCID` line above the package name in the homepage top summary card after asset information is loaded.
|
||||
|
||||
#### Scenario: Asset info returns an ICCID value
|
||||
- **WHEN** the homepage loads asset information successfully
|
||||
- **AND** the response contains `data.iccid`
|
||||
- **THEN** the top summary card SHALL display `ICCID: <value>` above the package name
|
||||
|
||||
#### Scenario: Asset info does not return an ICCID value
|
||||
- **WHEN** the homepage loads asset information successfully
|
||||
- **AND** `data.iccid` is empty or missing
|
||||
- **THEN** the top summary card SHALL still display the `ICCID` field
|
||||
- **AND** the field SHALL show a stable fallback value
|
||||
|
||||
### Requirement: Homepage ICCID SHALL use the asset info response as the source of truth
|
||||
The system SHALL source the homepage `ICCID` value from `data.iccid` returned by `/api/c/v1/asset/info?identifier=...`.
|
||||
|
||||
#### Scenario: Homepage refreshes asset information
|
||||
- **WHEN** the homepage reads or refreshes asset information from `/api/c/v1/asset/info`
|
||||
- **THEN** the `ICCID` shown in the top summary card SHALL be updated from `data.iccid`
|
||||
- **AND** the header SHALL NOT depend on deriving the display value from a different ICCID field for this summary position
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Device Exchange Shipping Entry
|
||||
|
||||
- [x] 1.1 Add `element-china-area-data` and integrate province/city/district selection into `pages/device-exchange/device-exchange.vue`
|
||||
- [x] 1.2 Replace the single shipping-address input with structured region selection plus a separate detailed-address input
|
||||
- [x] 1.3 Add a pasted shipping-info input and parse recipient name, recipient phone, and address content into editable form fields
|
||||
- [x] 1.4 Compose the selected region text and detailed address into `recipient_address` when submitting `/api/c/v1/exchange/{id}/shipping-info`
|
||||
- [x] 1.5 Preserve edit compatibility for previously saved combined addresses
|
||||
|
||||
## 2. Homepage ICCID Summary
|
||||
|
||||
- [x] 2.1 Read `data.iccid` from `assetApi.getInfo(identifier)` into homepage state
|
||||
- [x] 2.2 Render `ICCID` above the package name in `components/UserInfoCard.vue`
|
||||
- [x] 2.3 Show a stable fallback when `data.iccid` is absent
|
||||
|
||||
## 3. Regression Verification
|
||||
|
||||
- [x] 3.1 Verify the device exchange popup blocks submission when region selection or detailed address is incomplete
|
||||
- [x] 3.2 Verify a pasted shipping string can prefill recipient name, phone number, and address with manual correction still available
|
||||
- [x] 3.3 Verify the exchange shipping submit payload remains compatible with the existing backend contract
|
||||
- [x] 3.4 Verify the homepage top summary shows `ICCID` from `/api/c/v1/asset/info` above the package name
|
||||
@@ -0,0 +1,136 @@
|
||||
# Exchange Flow Type Support - Implementation Summary
|
||||
|
||||
## 实施完成 ✅
|
||||
|
||||
### 变更概述
|
||||
|
||||
根据后端换货流程升级,前端已完成对新增字段的支持:
|
||||
- `flow_type`: 流程类型(shipping/direct)
|
||||
- `flow_type_name`: 流程类型名称
|
||||
- `shipped_at`: 发货时间
|
||||
- `completed_at`: 完成时间
|
||||
|
||||
### 已完成的工作
|
||||
|
||||
#### 1. 创建 OpenSpec 文档
|
||||
- ✅ `proposal.md` - 提案说明
|
||||
- ✅ `specs/exchange-flow-type-display/spec.md` - 详细规格
|
||||
- ✅ `tasks.md` - 任务清单
|
||||
|
||||
#### 2. 代码实现
|
||||
**修改文件:** `pages/device-exchange/device-exchange.vue`
|
||||
|
||||
**新增显示内容:**
|
||||
```vue
|
||||
<!-- 换货类型 - 始终显示 -->
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">换货类型</view>
|
||||
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 发货时间 - 条件显示 -->
|
||||
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
|
||||
<view class="info-label">发货时间</view>
|
||||
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 完成时间 - 条件显示 -->
|
||||
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
|
||||
<view class="info-label">完成时间</view>
|
||||
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
#### 1. 后端保证的行为
|
||||
- `GET /api/c/v1/exchange/pending` 仅返回 `shipping` 类型的进行中换货单
|
||||
- `direct` 类型创建后立即完成,不会出现在待处理列表
|
||||
- 前端无需额外过滤或判断
|
||||
|
||||
#### 2. 向后兼容
|
||||
- 历史数据没有 `flow_type_name` 时,默认显示"物流换货"
|
||||
- 新增字段都是条件显示,不影响现有数据展示
|
||||
- 原有的按钮和表单逻辑保持不变
|
||||
|
||||
#### 3. 无需修改的部分
|
||||
- ✅ API 模块 (`api/modules/exchange.js`) - 自动透传所有响应字段
|
||||
- ✅ 表单提交逻辑 - 后端负责流程类型校验
|
||||
- ✅ 按钮显示逻辑 - 因为 direct 类型不会进入待处理状态
|
||||
|
||||
### 显示逻辑
|
||||
|
||||
| 字段 | 显示条件 | 默认值 |
|
||||
|------|---------|--------|
|
||||
| 换货类型 | 始终显示 | "物流换货"(当 `flow_type_name` 为空时) |
|
||||
| 发货时间 | `shipped_at` 不为空 | 不显示 |
|
||||
| 完成时间 | `completed_at` 不为空 且 `status === 4` | 不显示 |
|
||||
|
||||
### 测试场景
|
||||
|
||||
需要测试的场景(待后端接口就绪后验证):
|
||||
|
||||
1. **待填写信息** (`status: 1`)
|
||||
- 显示:换货类型、换货原因、申请时间
|
||||
- 显示"填写信息"按钮
|
||||
|
||||
2. **待发货** (`status: 2`)
|
||||
- 显示:换货类型、换货原因、申请时间、收件信息
|
||||
- 不显示发货时间
|
||||
|
||||
3. **已发货** (`status: 3`)
|
||||
- 显示:换货类型、换货原因、申请时间、发货时间、收件信息
|
||||
- 不显示按钮
|
||||
|
||||
4. **已完成** (`status: 4`)
|
||||
- 显示:换货类型、换货原因、申请时间、发货时间、完成时间、收件信息
|
||||
- 不显示按钮
|
||||
|
||||
5. **无待处理换货单**
|
||||
- 显示空状态:"暂无换货记录"
|
||||
|
||||
### API 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"exchange_no": "EXC20260603001",
|
||||
"old_asset_type": "iot_card",
|
||||
"old_identifier": "8986012345678901234",
|
||||
"exchange_reason": "设备损坏",
|
||||
"status": 2,
|
||||
"status_text": "待发货",
|
||||
"flow_type": "shipping",
|
||||
"flow_type_name": "物流换货",
|
||||
"shipped_at": null,
|
||||
"completed_at": null,
|
||||
"created_at": "2026-06-03T10:00:00+08:00",
|
||||
"recipient_name": "张三",
|
||||
"recipient_phone": "13800138000",
|
||||
"recipient_address": "北京市朝阳区xxx路xxx号"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 影响范围
|
||||
|
||||
- **修改文件**: 1 个 (`pages/device-exchange/device-exchange.vue`)
|
||||
- **新增文档**: 3 个(proposal, spec, tasks)
|
||||
- **影响用户**: 使用换货功能的客户端用户
|
||||
- **兼容性**: 完全向后兼容,不影响现有流程
|
||||
|
||||
### 后续工作
|
||||
|
||||
- [ ] 等待后端接口部署
|
||||
- [ ] 在测试环境验证所有测试场景
|
||||
- [ ] 确认不同状态下的显示效果
|
||||
- [ ] 验证历史数据的兼容性
|
||||
|
||||
---
|
||||
|
||||
**实施日期**: 2026-06-03
|
||||
**实施人员**: Claude
|
||||
**状态**: ✅ 代码实现完成,待测试验证
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
The backend exchange system now supports two flow types: **shipping** (logistics-based exchange requiring address collection and shipment) and **direct** (immediate completion without logistics). The current frontend client only handles the original shipping flow and does not process the new `flow_type`, `flow_type_name`, `shipped_at`, and `completed_at` response fields.
|
||||
|
||||
This prevents the frontend from correctly displaying exchange records that may include both flow types and from adapting UI behavior based on flow type constraints.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Update the exchange pending query and shipping info submission to handle new response fields: `flow_type`, `flow_type_name`, `shipped_at`, `completed_at`
|
||||
- Preserve existing behavior where the pending exchange API only returns `shipping` type exchanges (status 1/2/3), as `direct` exchanges are completed immediately and never appear in pending state
|
||||
- Display flow type information in the exchange detail view
|
||||
- Ensure shipping info submission validates that the exchange is `flow_type = shipping` and `status = 1` (backend enforces this, frontend should handle error gracefully)
|
||||
- No UI flow changes required since `direct` type exchanges will not appear in the pending list by design
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `exchange-flow-type-display`: Display flow type and completion timestamps in exchange records
|
||||
|
||||
### Modified Capabilities
|
||||
- `exchange-pending-query`: Handle new response fields from `/api/c/v1/exchange/pending`
|
||||
- `exchange-shipping-submission`: Handle new response fields from `/api/c/v1/exchange/{id}/shipping-info`
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `pages/device-exchange/device-exchange.vue`, `api/modules/exchange.js`
|
||||
- Affected API usage: `/api/c/v1/exchange/pending`, `/api/c/v1/exchange/{id}/shipping-info`
|
||||
- No breaking changes: existing shipping flow behavior remains unchanged
|
||||
- Backend ensures `direct` type exchanges are not returned by pending query, so frontend maintains current UX
|
||||
@@ -0,0 +1,195 @@
|
||||
# Exchange Flow Type Display Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Update the exchange display to show the new flow type and timestamp fields returned by the backend exchange APIs.
|
||||
|
||||
## API Response Changes
|
||||
|
||||
### GET /api/c/v1/exchange/pending
|
||||
|
||||
**New Response Fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"timestamp": "2026-06-03T10:00:00+08:00",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"exchange_no": "EXC20260603001",
|
||||
"order_no": "EXC20260603001",
|
||||
"old_asset_type": "iot_card",
|
||||
"old_identifier": "8986012345678901234",
|
||||
"exchange_reason": "设备损坏",
|
||||
"status": 2,
|
||||
"status_text": "待发货",
|
||||
"flow_type": "shipping",
|
||||
"flow_type_name": "物流换货",
|
||||
"shipped_at": null,
|
||||
"completed_at": null,
|
||||
"created_at": "2026-06-03T10:00:00+08:00",
|
||||
"recipient_name": "张三",
|
||||
"recipient_phone": "13800138000",
|
||||
"recipient_address": "北京市朝阳区xxx路xxx号"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `flow_type` | string | 流程类型: `shipping` (物流换货) or `direct` (直接换货)。历史数据为空时视为 `shipping` |
|
||||
| `flow_type_name` | string | 流程类型名称: "物流换货" or "直接换货" |
|
||||
| `shipped_at` | string\|null | 发货时间,仅 `shipping` 流程发货后有值 |
|
||||
| `completed_at` | string\|null | 换货完成时间,换货完成后有值 |
|
||||
|
||||
**Backend Behavior:**
|
||||
|
||||
- Only returns exchanges where `flow_type = shipping` AND `status IN (1, 2, 3)`
|
||||
- `direct` type exchanges are never returned (they complete immediately on creation)
|
||||
- If no pending exchange exists, returns `data: null`
|
||||
|
||||
### POST /api/c/v1/exchange/{id}/shipping-info
|
||||
|
||||
**Response includes the same new fields as above**
|
||||
|
||||
**Backend Constraints:**
|
||||
|
||||
- Only accepts `flow_type = shipping` AND `status = 1`
|
||||
- Returns 400 error if `flow_type = direct`: `{"code": 400, "message": "流程类型不支持该操作"}`
|
||||
- Returns 400 error if status is not 1: `{"code": 400, "message": "当前状态不允许填写收货信息"}`
|
||||
|
||||
## UI Changes
|
||||
|
||||
### Exchange Detail Card
|
||||
|
||||
Add flow type display in the exchange info section:
|
||||
|
||||
**Before:**
|
||||
```
|
||||
换货原因: 设备损坏
|
||||
申请时间: 2026-06-03 10:00:00
|
||||
收件人: 张三
|
||||
联系电话: 13800138000
|
||||
收货地址: 北京市朝阳区xxx路xxx号
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
换货类型: 物流换货
|
||||
换货原因: 设备损坏
|
||||
申请时间: 2026-06-03 10:00:00
|
||||
发货时间: 2026-06-04 15:30:00 (仅已发货时显示)
|
||||
完成时间: 2026-06-05 10:00:00 (仅已完成时显示)
|
||||
收件人: 张三
|
||||
联系电话: 13800138000
|
||||
收货地址: 北京市朝阳区xxx路xxx号
|
||||
```
|
||||
|
||||
**Display Rules:**
|
||||
|
||||
1. **换货类型**: Always display `flow_type_name` if present, fallback to "物流换货" if empty
|
||||
2. **发货时间**: Only display if `shipped_at` is not null
|
||||
3. **完成时间**: Only display if `completed_at` is not null and `status === 4`
|
||||
|
||||
### Error Handling
|
||||
|
||||
When submitting shipping info fails with flow type error:
|
||||
|
||||
```javascript
|
||||
// Existing error handling should catch and display backend error message
|
||||
catch (error) {
|
||||
console.error('提交换货信息失败', error);
|
||||
// Backend will return: {"code": 400, "message": "流程类型不支持该操作"}
|
||||
// Default error toast will show this message
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Update API Module Types
|
||||
|
||||
No changes needed to `api/modules/exchange.js` - it already passes through all response fields.
|
||||
|
||||
### 2. Update Exchange Page Template
|
||||
|
||||
File: `pages/device-exchange/device-exchange.vue`
|
||||
|
||||
Add flow type and timestamp displays:
|
||||
|
||||
```vue
|
||||
<view class="exchange-info">
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">换货类型</view>
|
||||
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">换货原因</view>
|
||||
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">申请时间</view>
|
||||
<view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
|
||||
<view class="info-label">发货时间</view>
|
||||
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
|
||||
<view class="info-label">完成时间</view>
|
||||
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
|
||||
</view>
|
||||
<!-- Existing recipient fields -->
|
||||
</view>
|
||||
```
|
||||
|
||||
### 3. No Button Logic Changes
|
||||
|
||||
Since `direct` type exchanges never appear in pending (they complete immediately), the existing button visibility logic remains valid:
|
||||
|
||||
```vue
|
||||
<view class="exchange-actions" v-if="exchangeData.status === 1">
|
||||
<button class="btn-apple btn-primary" @tap="openPopup">填写信息</button>
|
||||
</view>
|
||||
```
|
||||
|
||||
Backend enforces that only `shipping` + status 1 can submit shipping info, so frontend doesn't need additional flow type checks.
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### 1. Shipping Flow Exchange (Existing Behavior)
|
||||
|
||||
- Load pending exchange with `flow_type: "shipping"`, `status: 1`
|
||||
- Verify "换货类型: 物流换货" displays
|
||||
- Verify "填写信息" button shows
|
||||
- Submit shipping info successfully
|
||||
- Verify response includes new fields
|
||||
|
||||
### 2. Shipped Exchange
|
||||
|
||||
- Load exchange with `flow_type: "shipping"`, `status: 3`, `shipped_at: "2026-06-04T15:30:00+08:00"`
|
||||
- Verify "发货时间" row displays
|
||||
- Verify no action buttons show
|
||||
|
||||
### 3. Completed Exchange
|
||||
|
||||
- Load exchange with `flow_type: "shipping"`, `status: 4`, `completed_at: "2026-06-05T10:00:00+08:00"`
|
||||
- Verify "完成时间" row displays
|
||||
- Verify no action buttons show
|
||||
|
||||
### 4. No Pending Exchange
|
||||
|
||||
- Call API with no pending exchange
|
||||
- Verify empty state shows: "暂无换货记录"
|
||||
|
||||
### 5. Error Handling (Edge Case)
|
||||
|
||||
If somehow a `direct` type exchange appeared and user tried to submit shipping info (should not happen), backend returns 400 error which existing error handler will toast.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Historical data without `flow_type` will be treated as `shipping` (per backend spec)
|
||||
- All new fields are optional in frontend display logic
|
||||
- Existing shipping submission flow is unchanged
|
||||
17
openspec/changes/update-exchange-flow-type-support/tasks.md
Normal file
17
openspec/changes/update-exchange-flow-type-support/tasks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Update Exchange Detail Display
|
||||
- [x] Add flow type display row in exchange info section
|
||||
- [x] Add shipped_at display row (conditional on value)
|
||||
- [x] Add completed_at display row (conditional on value and status)
|
||||
- [x] Ensure backward compatibility for historical data without flow_type
|
||||
|
||||
## 2. Testing
|
||||
- [ ] Test with shipping flow exchange (status 1, 2, 3)
|
||||
- [ ] Test with completed exchange showing completion time
|
||||
- [ ] Test with shipped exchange showing ship time
|
||||
- [ ] Test empty state when no pending exchange
|
||||
- [ ] Test shipping info submission success with new response fields
|
||||
|
||||
## Files Modified
|
||||
- [x] `pages/device-exchange/device-exchange.vue`: Added new field displays
|
||||
25
openspec/changes/update-index-phone-bind-gate/proposal.md
Normal file
25
openspec/changes/update-index-phone-bind-gate/proposal.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
The current login flow decides whether to send the user to the bind-phone page based on `loginData.need_bind_phone`. This makes the phone-binding gate happen only at login time, which is fragile because the actual requirement belongs to entering the homepage, not only to completing login. As a result, the app can enter an inconsistent state when the user reaches `index` through other paths or when the latest bind requirement should be re-evaluated on homepage entry.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Move the mandatory phone-binding gate out of `pages/login/login.vue`
|
||||
- Make login success redirect to `pages/index/index` consistently
|
||||
- Require `pages/index/index.vue` to check whether the current account still needs phone binding every time the page is entered
|
||||
- Redirect to `pages/bind/bind` from `index` when binding is required, instead of deciding that only during login
|
||||
- Keep the bind-phone page as the place where the user completes the required binding flow
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `index-phone-bind-gate`: Define homepage-entry binding rules so phone binding is enforced from `index` instead of only from login
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `pages/login/login.vue`, `pages/index/index.vue`, `pages/bind/bind.vue`
|
||||
- Affected flow: post-login routing, homepage entry, mandatory bind-phone redirect
|
||||
- No backend API changes
|
||||
- No login credential or bind-phone API contract changes
|
||||
@@ -0,0 +1,44 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Login success SHALL NOT own the bind-phone routing decision
|
||||
The system SHALL stop deciding mandatory bind-phone routing inside the login success handler and SHALL route successful login into the homepage entry flow instead.
|
||||
|
||||
#### Scenario: Login succeeds for a user who may need binding
|
||||
- **WHEN** the user completes login successfully
|
||||
- **THEN** the system SHALL route the user to `pages/index/index`
|
||||
- **AND** the system SHALL NOT branch directly from the login page to the bind-phone page based only on login-time routing logic
|
||||
|
||||
### Requirement: Index entry SHALL enforce the bind-phone gate
|
||||
The system SHALL determine whether phone binding is required whenever the homepage is entered and SHALL redirect to the bind-phone page if the current account is not yet bound.
|
||||
|
||||
#### Scenario: User enters index and binding is required
|
||||
- **WHEN** the user enters `pages/index/index`
|
||||
- **AND** the latest account state indicates the phone is not yet bound
|
||||
- **THEN** the system SHALL redirect the user from `index` to `pages/bind/bind`
|
||||
|
||||
#### Scenario: User enters index and binding is not required
|
||||
- **WHEN** the user enters `pages/index/index`
|
||||
- **AND** the latest account state indicates the phone is already bound
|
||||
- **THEN** the system SHALL keep the user on the homepage
|
||||
|
||||
#### Scenario: User re-enters index later without binding
|
||||
- **WHEN** the user enters `pages/index/index` again through a later navigation path
|
||||
- **AND** the latest account state still indicates the phone is not yet bound
|
||||
- **THEN** the system SHALL re-apply the bind-phone redirect
|
||||
- **AND** the redirect SHALL NOT depend on whether the user just came from the login page
|
||||
|
||||
### Requirement: Bind completion SHALL remain compatible with homepage entry
|
||||
The system SHALL preserve the existing bind-phone completion destination to the homepage while allowing the homepage gate to be the source of truth for whether binding is still required.
|
||||
|
||||
#### Scenario: User completes binding and returns to index
|
||||
- **WHEN** the user completes phone binding successfully
|
||||
- **THEN** the system SHALL return the user to `pages/index/index`
|
||||
- **AND** entering `index` afterward SHALL no longer redirect if the account is now bound
|
||||
|
||||
### Requirement: Index phone-bind gate SHALL NOT change login or bind API contracts
|
||||
The system SHALL preserve the existing login API, bind-phone API, token handling, and account retrieval contracts while changing only the routing responsibility.
|
||||
|
||||
#### Scenario: Routing responsibility changes without API change
|
||||
- **WHEN** the new homepage-entry gate is applied
|
||||
- **THEN** the system SHALL change only where the bind requirement is evaluated for routing
|
||||
- **AND** the system SHALL NOT require backend API contract changes
|
||||
23
openspec/changes/update-index-phone-bind-gate/tasks.md
Normal file
23
openspec/changes/update-index-phone-bind-gate/tasks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
## 1. Login Redirect Responsibility
|
||||
|
||||
- [x] 1.1 Remove the bind-phone branching decision from the login success handler
|
||||
- [x] 1.2 Make login success enter `pages/index/index` consistently
|
||||
|
||||
## 2. Index Entry Binding Gate
|
||||
|
||||
- [x] 2.1 Add a homepage-entry binding check in `pages/index/index.vue`
|
||||
- [x] 2.2 Re-evaluate whether binding is required every time `index` is entered, not only after login
|
||||
- [x] 2.3 Redirect from `index` to the bind-phone page when binding is still required
|
||||
|
||||
## 3. Bind Flow Compatibility
|
||||
|
||||
- [x] 3.1 Keep the bind-phone completion flow returning to `index`
|
||||
- [x] 3.2 Ensure users who are already bound are not redirected away from `index`
|
||||
- [x] 3.3 Ensure users who return to `index` without binding are checked again and redirected again if binding is still required
|
||||
|
||||
## 4. Regression Verification
|
||||
|
||||
- [x] 4.1 Verify login success always routes into `index`
|
||||
- [x] 4.2 Verify entering `index` without a bound phone redirects to the bind page
|
||||
- [x] 4.3 Verify entering `index` with a bound phone stays on the homepage
|
||||
- [x] 4.4 Verify re-entering `index` through later navigation still rechecks binding state
|
||||
63
openspec/changes/update-july-h5-c-iteration/design.md
Normal file
63
openspec/changes/update-july-h5-c-iteration/design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
## Context
|
||||
|
||||
本次迭代同时调整认证入口、资产初始化、套餐购买、钱包充值、订单查询和站内通知入口。后端已经通过资产信息、充值校验、订单和通知接口返回策略与业务状态,H5/C 端需要消费这些结果,而不是复制后端规则。当前仓库已经存在部分支付、实名和通知页面,因此提案以增量收敛行为为主。
|
||||
|
||||
接口约定以 `docs/所需接口文档/new-api.md` 为准;登录限制接口和通知接口的调用路径同时以 `docs/产品迭代7月份/七月迭代H5_C端改动说明.md` 中列出的既有路径为准。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals: 让登录限制、实名策略、支付方式、强充约束、下架套餐续费、预计到期时间、临期/换货通知及订单标识展示均以后端返回为准。
|
||||
- Goals: 保持现有 H5/C API 路径和支付入口,完成前端参数和展示规则收敛。
|
||||
- Non-Goals: 不新增后端接口,不修改订单历史快照,不由 H5/C 调用企微审批回调,也不实现七月说明中列出的排除项。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Backend is the source of truth
|
||||
|
||||
资产初始化统一读取 `effective_realname_policy`、`realname_required`、`real_name_status`、`allowed_payment_methods`、`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status` 和 `is_expiring`。前端不再根据 `asset_type`、卡/设备组合或本地枚举推导实名和支付规则;设备是否已实名也以服务端最终状态为准。
|
||||
|
||||
### 2. Keep login failure before token persistence
|
||||
|
||||
`POST /api/c/v1/auth/verify-asset` 成功后才允许进入后续微信登录,并保存本次返回的 `asset_token`。业务失败时直接展示后端 `msg`,当前登录尝试不得继续获取或覆盖资产 Token;Token 的签发和吊销仍由服务端负责。
|
||||
|
||||
### 3. Normalize payment payloads at the API boundary
|
||||
|
||||
订单和充值页面只提交用户从后端允许列表中选择的 `payment_method`。仅在 `payment_method = wechat` 时提交接口要求的 `app_type`;钱包和支付宝不添加无关的微信字段。金额在 UI 层转换为元,在请求层保持整数分。
|
||||
|
||||
支付创建成功后,根据 `pay_config` 或 `payment_link` 进入既有支付处理;返回页面或支付完成后使用订单详情/状态查询确认结果,不能仅凭前端跳转成功判定已支付。
|
||||
|
||||
### 4. Separate catalog visibility from renewal eligibility
|
||||
|
||||
普通套餐列表继续只展示可售套餐。老客户续费使用资产信息中的 `current_package_id` 或历史订单中的 `package_ids` 作为已知套餐 ID,复用 `POST /api/c/v1/orders/create`,不创建“下架套餐续费”专用接口。前端不得修改历史订单数据或把下架套餐重新放入普通新客购买列表。
|
||||
|
||||
### 5. Display the final expiry estimate
|
||||
|
||||
资产页面优先展示 `estimated_final_expires_at`,并用 `expiry_estimate_status` 判断其是否可展示,用 `days_until_final_expiry` 和 `is_expiring` 控制剩余天数及临期样式。前端不根据当前套餐到期时间自行累加计算最终日期;无可用估算时显示明确的空状态。
|
||||
|
||||
### 6. Reuse the existing customer notification entry
|
||||
|
||||
首页或通知入口读取未读数,通知页面读取列表,用户查看/点击通知后调用单条已读接口。套餐临期的 15/7/3 天触发和 0~3 天的优先级由后端通知数据表达,前端只负责按等级排序/展示和更新未读数。换货通知沿用同一套入口,不新增营销、ERP 或业务员提醒通道。
|
||||
|
||||
本提案依赖进行中的 `add-personal-notifications` 变更提供通知数据的可见性、分页和幂等已读语义;本提案只定义 H5/C 的消费方式。
|
||||
|
||||
### 7. Preserve server snapshots in order views
|
||||
|
||||
订单列表和详情使用后端返回的 `purchase_role` 与 `asset_identifier`。设备显示优先使用 `virtual_no`,为空时使用 `imei`,绝不以 `sn` 代替;缺失数据保持空占位,不由前端伪造。金额继续按分转元展示。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 后端字段缺失或为空时,页面可能无法给出策略或到期日期;通过统一空状态和错误提示避免前端猜测。
|
||||
- 同一支付入口可能同时收到 `pay_config` 与 `payment_link` 为空的结果;前端必须保留既有错误处理并允许通过订单状态查询恢复。
|
||||
- `add-personal-notifications` 与本提案同时推进时,需要先确认通知 API 的返回字段和分页参数一致;本提案不重复修改该服务契约。
|
||||
- 下架套餐续费依赖资产或历史订单提供合法的套餐 ID;若两者均不存在,应明确提示不可续费,而不是从普通列表猜测套餐。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 先更新 API 封装和数据归一化,再逐个接入登录、资产、支付、套餐、通知和订单页面。
|
||||
2. 使用接口模拟数据覆盖策略冲突、支付方式变化、强充、临期等级、下架套餐和空标识场景。
|
||||
3. 联调确认支付结果查询、通知已读幂等性及历史订单快照后发布。
|
||||
4. 若任一后端字段未上线,回退对应 UI 展示入口,不回退到前端硬编码业务规则。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None. The proposal follows the July change description and the provided API document; backend response details not listed there remain opaque to the client and are displayed through existing generic error handling.
|
||||
41
openspec/changes/update-july-h5-c-iteration/proposal.md
Normal file
41
openspec/changes/update-july-h5-c-iteration/proposal.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Change: Update July H5/C iteration behavior
|
||||
|
||||
## Why
|
||||
|
||||
七月迭代要求 H5/C 端将登录、实名、支付、套餐续费、到期提醒、站内通知和订单展示统一切换为以后端业务结果为准。当前页面和 API 封装仍存在按资产类型或前端本地规则判断的逻辑,部分订单提交也没有始终传递用户选择的支付方式,容易造成错误引导、支付参数不完整和订单信息展示失真。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在资产校验登录流程中处理店铺 C 端登录限制;被限制时展示后端业务错误,不保存或继续使用本次登录的资产 Token。
|
||||
- 使用资产信息接口返回的 `effective_realname_policy`、`realname_required` 和 `real_name_status` 驱动实名状态展示及下单前后的实名流程。
|
||||
- 使用 `allowed_payment_methods` 渲染支付方式,并在套餐下单、强充校验和钱包充值时按后端规则提交 `payment_method`;微信支付按要求提交 `app_type`。
|
||||
- 普通套餐列表隐藏下架套餐,同时允许正在使用下架套餐的老客户通过 `current_package_id` 或历史订单 `package_ids` 复用 `/api/c/v1/orders/create` 续费。
|
||||
- 在资产详情、资产列表等页面展示后端计算的 `estimated_final_expires_at`,并使用临期字段进行高亮。
|
||||
- 接入 C 端站内通知的未读数、列表和单条已读接口,展示套餐临期和换货通知,并按后端临期等级处理提醒优先级。
|
||||
- 在订单列表和详情展示后端返回的 `purchase_role` 与 `asset_identifier`;设备标识按 `virtual_no` 优先、`imei` 兜底,不使用 SN 冒充。
|
||||
- 保持金额接口以分传输、页面以元展示,并在支付参数或支付链接返回后通过订单状态查询确认支付结果。
|
||||
|
||||
本提案不包含首页隐藏设备 ICCID、行业卡未实名复机、状态同步/运营商回调、企微审批回调、原路退款、跨品类换货、分销码或佣金提现等明确排除项。H5/C 端不调用企微审批回调接口。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `c-login-access`: Enforce the shop-level C-end login restriction during asset verification
|
||||
- `asset-realname-flow`: Drive real-name behavior from backend asset policy and status
|
||||
- `asset-expiry-display`: Display the estimated final package expiry and expiry state
|
||||
- `backend-driven-payment`: Render and submit payment methods from backend policy
|
||||
- `legacy-package-renewal`: Allow eligible existing customers to renew discontinued packages
|
||||
- `c-notification-reminders`: Surface expiry and exchange notifications in the H5/C client
|
||||
- `order-display-fields`: Render order role and server-provided asset identifier snapshots
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `personal-notifications`: This change consumes the notification endpoints and does not redefine their backend visibility, pagination, or read-state contract. Coordinate with the existing `add-personal-notifications` change.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `pages/login/login.vue`, `pages/index/index.vue`, `pages/auth/auth.vue`, `pages/switch/switch.vue`, `pages/package-order/package-order.vue`, `pages/my-wallet/my-wallet.vue`, `pages/order-list/order-list.vue`, `pages/notifications/notifications.vue`, related notification/payment components, and `api/modules/{auth,asset,order,wallet,notification}.js`
|
||||
- Affected APIs: `/api/c/v1/auth/verify-asset`, `/api/c/v1/asset/info`, `/api/c/v1/wallet/recharge-check`, `/api/c/v1/orders/create`, `/api/c/v1/wallet/recharge`, `/api/c/v1/orders`, `/api/c/v1/orders/{id}`, `/api/c/v1/notifications/unread-count`, `/api/c/v1/notifications`, and `/api/c/v1/notifications/{id}/read`
|
||||
- No new backend endpoint is required; the client adopts the response fields and request rules described in `docs/所需接口文档/new-api.md` and the July H5/C change description.
|
||||
- Existing WeChat payment parameter handling, Alipay payment-link handling, wallet payment, payment submit guards, and notification list APIs must remain compatible.
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Asset views SHALL display the estimated final package expiry
|
||||
|
||||
Asset detail and summary views using `/api/c/v1/asset/info` SHALL prefer `estimated_final_expires_at` over `current_package_expires_at` for the user-facing final expiry. The client SHALL use `expiry_estimate_status`, `days_until_final_expiry`, and `is_expiring` when those fields are returned, and SHALL NOT calculate a replacement final expiry locally.
|
||||
|
||||
#### Scenario: Exact final expiry is available
|
||||
|
||||
- **WHEN** `expiry_estimate_status = exact` and `estimated_final_expires_at` is present
|
||||
- **THEN** the client SHALL display the estimated final expiry date
|
||||
- **AND** the client SHALL display the returned remaining-day value when available
|
||||
|
||||
#### Scenario: Asset is approaching final expiry
|
||||
|
||||
- **WHEN** `is_expiring = true` or the backend returns an applicable expiry level
|
||||
- **THEN** the client SHALL apply the existing expiry highlight/reminder presentation
|
||||
- **AND** the client SHALL use the backend value rather than recalculating the threshold
|
||||
|
||||
#### Scenario: Final expiry cannot be estimated
|
||||
|
||||
- **WHEN** `expiry_estimate_status` is `none`, `waiting_activation`, or `invalid_data`, or the estimated date is null
|
||||
- **THEN** the client SHALL show the corresponding empty/pending state
|
||||
- **AND** the client SHALL not present `current_package_expires_at` as if it were the final calculated expiry
|
||||
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Real-name behavior SHALL use the effective backend asset policy
|
||||
|
||||
The H5/C client SHALL use `GET /api/c/v1/asset/info?identifier=...` as the source of truth for real-name behavior. It SHALL consume `effective_realname_policy`, `realname_required`, and `real_name_status` and SHALL NOT infer policy or final device real-name status from asset type, card type, device type, or frontend-only rules.
|
||||
|
||||
#### Scenario: Asset does not require real name
|
||||
|
||||
- **WHEN** asset information returns `effective_realname_policy = none` or `realname_required = false`
|
||||
- **THEN** the client SHALL show the asset as not requiring real-name completion
|
||||
- **AND** the client SHALL not block the normal order flow for real-name completion
|
||||
|
||||
#### Scenario: Real name is required before ordering
|
||||
|
||||
- **WHEN** asset information returns `effective_realname_policy = before_order`, `realname_required = true`, and `real_name_status` as not completed
|
||||
- **THEN** the client SHALL show the real-name requirement
|
||||
- **AND** the client SHALL prevent package order submission until the existing real-name flow completes
|
||||
|
||||
#### Scenario: Real name is required after ordering
|
||||
|
||||
- **WHEN** the effective real-name policy is `after_order`
|
||||
- **AND** `/api/c/v1/device/cards` returns no card with completed real-name authentication
|
||||
- **AND** package history contains neither a pending package (`status = 0`) nor an active package (`status = 1`)
|
||||
- **THEN** selecting a real-name entry on the homepage or operator-switch page SHALL block direct real-name authentication
|
||||
- **AND** the client SHALL show a confirmation prompt that can navigate to package ordering
|
||||
|
||||
#### Scenario: Post-order real-name requirement is satisfied
|
||||
|
||||
- **WHEN** the effective real-name policy is `after_order`
|
||||
- **AND** `/api/c/v1/device/cards` returns at least one authenticated card, or package history contains a pending or active package
|
||||
- **THEN** the homepage and operator-switch real-name entries SHALL retain the existing real-name authentication flow
|
||||
|
||||
#### Scenario: Device status is resolved from server output
|
||||
|
||||
- **WHEN** a device has cards with different local-looking real-name states but the asset info response returns the effective device `real_name_status`
|
||||
- **THEN** the client SHALL display and use that returned effective status
|
||||
- **AND** the client SHALL not replace it with a card/device inference
|
||||
@@ -0,0 +1,70 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Payment options SHALL be rendered from backend permissions
|
||||
|
||||
The H5/C client SHALL use `allowed_payment_methods` from asset information and the recharge-check response to determine which payment methods are visible and selectable. It SHALL support the backend values `wallet`, `wechat`, and `alipay` where returned, and SHALL not hard-code a card/device payment-method matrix.
|
||||
|
||||
#### Scenario: Asset payment methods are returned
|
||||
|
||||
- **WHEN** asset information returns a non-empty `allowed_payment_methods` list
|
||||
- **THEN** the package payment UI SHALL render exactly the methods allowed by that list
|
||||
- **AND** the client SHALL not offer a method absent from the list
|
||||
|
||||
#### Scenario: Recharge permissions differ from asset permissions
|
||||
|
||||
- **WHEN** `/api/c/v1/wallet/recharge-check` returns its own `allowed_payment_methods` value
|
||||
- **THEN** the wallet recharge UI SHALL use the recharge-check value for that recharge attempt
|
||||
- **AND** the client SHALL not reuse a stale package-payment method list
|
||||
|
||||
### Requirement: Payment creation SHALL submit the selected method and the required WeChat app type
|
||||
|
||||
The client SHALL submit `identifier`, the relevant package or amount fields, and the user-selected `payment_method` to the applicable creation endpoint. It SHALL submit `app_type` only when `payment_method = wechat`, using the endpoint-defined value `official_account` or `miniapp`.
|
||||
|
||||
#### Scenario: Create a package order
|
||||
|
||||
- **WHEN** the user submits an allowed package payment method
|
||||
- **THEN** the client SHALL call `POST /api/c/v1/orders/create` with `identifier`, `package_ids`, and the selected `payment_method`
|
||||
- **AND** the client SHALL include `app_type` for WeChat payment as required
|
||||
|
||||
#### Scenario: Create a wallet recharge
|
||||
|
||||
- **WHEN** the user submits an allowed recharge method and an amount within backend limits
|
||||
- **THEN** the client SHALL call `POST /api/c/v1/wallet/recharge` with the integer amount in cents, identifier, and selected payment method
|
||||
- **AND** the client SHALL include `app_type` only for WeChat payment
|
||||
|
||||
#### Scenario: User-facing currency conversion
|
||||
|
||||
- **WHEN** an amount is returned by an API in cents
|
||||
- **THEN** the client SHALL display the corresponding yuan value
|
||||
- **AND** the client SHALL submit the original integer-cent representation to the API
|
||||
|
||||
### Requirement: Recharge submission SHALL honor the backend pre-check
|
||||
|
||||
The client SHALL call `GET /api/c/v1/wallet/recharge-check?identifier=...` before starting a recharge. When `need_force_recharge = true`, it SHALL use `force_recharge_amount`, `min_amount`, `max_amount`, and `message` from the response to guide or block the recharge flow.
|
||||
|
||||
#### Scenario: Force recharge is required
|
||||
|
||||
- **WHEN** recharge-check returns `need_force_recharge = true`
|
||||
- **THEN** the client SHALL present the backend force-recharge amount and message
|
||||
- **AND** the client SHALL not submit a normal recharge that violates the returned requirement
|
||||
|
||||
#### Scenario: Recharge is permitted normally
|
||||
|
||||
- **WHEN** recharge-check returns `need_force_recharge = false` and the amount is within the returned range
|
||||
- **THEN** the client SHALL allow the user to select an allowed method and submit the recharge order
|
||||
|
||||
### Requirement: Payment results SHALL be confirmed by backend order state
|
||||
|
||||
After order creation, the client SHALL preserve existing handling for `pay_config` and `payment_link`, and SHALL use an order or recharge status query to confirm completion. A successful frontend redirect or payment-link return alone SHALL not be treated as proof of payment.
|
||||
|
||||
#### Scenario: Backend returns WeChat payment parameters
|
||||
|
||||
- **WHEN** a creation response contains `pay_config`
|
||||
- **THEN** the client SHALL invoke the existing WeChat payment flow
|
||||
- **AND** the client SHALL refresh backend payment status after the flow returns
|
||||
|
||||
#### Scenario: Backend returns a web payment link
|
||||
|
||||
- **WHEN** a creation response contains `payment_link`
|
||||
- **THEN** the client SHALL use the existing payment-link presentation/handling
|
||||
- **AND** the client SHALL confirm the resulting order or recharge state through the backend
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: C-end login SHALL honor asset verification access decisions
|
||||
|
||||
The H5/C client SHALL call `POST /api/c/v1/auth/verify-asset` before continuing the asset login flow. A business failure or C-end login restriction returned by the endpoint SHALL be shown using the backend business message, and the client SHALL NOT continue to obtain, persist, or use an asset token for that login attempt.
|
||||
|
||||
#### Scenario: Asset verification allows login
|
||||
|
||||
- **WHEN** asset verification succeeds and returns an asset token
|
||||
- **THEN** the client SHALL persist the identifier and returned asset token and continue the existing login flow
|
||||
|
||||
#### Scenario: Shop forbids a new C-end login
|
||||
|
||||
- **WHEN** asset verification returns a business failure indicating that the shop has forbidden C-end login
|
||||
- **THEN** the client SHALL display the backend error message
|
||||
- **AND** the client SHALL stop the current login flow before WeChat authorization or token persistence
|
||||
- **AND** the client SHALL not revoke an already-issued token as a side effect
|
||||
|
||||
#### Scenario: Asset verification fails without a usable token
|
||||
|
||||
- **WHEN** the verification request returns an error response or no usable asset token
|
||||
- **THEN** the client SHALL display the existing request/business error
|
||||
- **AND** the client SHALL leave the current login attempt unauthenticated
|
||||
@@ -0,0 +1,66 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: H5/C SHALL surface unread customer notifications
|
||||
|
||||
The H5/C client SHALL use `GET /api/c/v1/notifications/unread-count` for the homepage or notification-entry badge and `GET /api/c/v1/notifications` for the notification list. It SHALL consume the existing personal-notification contract and SHALL not add a separate API for expiry or exchange reminders.
|
||||
|
||||
#### Scenario: Unread notifications exist
|
||||
|
||||
- **WHEN** the unread-count endpoint returns a positive count
|
||||
- **THEN** the homepage or notification entry SHALL display the unread indicator using that count
|
||||
- **AND** the notification list entry SHALL remain available
|
||||
|
||||
#### Scenario: No unread notifications exist
|
||||
|
||||
- **WHEN** the unread-count endpoint returns zero
|
||||
- **THEN** the client SHALL clear the unread indicator
|
||||
- **AND** the notification entry SHALL not show a stale count
|
||||
|
||||
### Requirement: Expiry and exchange notices SHALL use the shared notification channel
|
||||
|
||||
The client SHALL display package-expiry and exchange-related notifications returned for the authenticated customer through the shared notification list or reminder popup. The backend notification data SHALL determine the expiry trigger and severity; the client SHALL not create independent timers that send business notifications.
|
||||
|
||||
#### Scenario: Package reaches a reminder threshold
|
||||
|
||||
- **WHEN** the notification service returns an unread package-expiry notice for the 15-day, 7-day, or 3-day threshold
|
||||
- **THEN** the client SHALL display it through the existing H5/C notification entry or popup
|
||||
- **AND** the client SHALL use the returned expiry level for presentation
|
||||
|
||||
#### Scenario: Critical expiry notice has highest priority
|
||||
|
||||
- **WHEN** unread expiry notices include a notice for 0 to 3 remaining days
|
||||
- **THEN** the client SHALL give that notice the highest reminder priority
|
||||
- **AND** the client SHALL not downgrade it based on a client-side date calculation
|
||||
|
||||
#### Scenario: Exchange notification is returned
|
||||
|
||||
- **WHEN** an exchange-related unread notice is returned after an exchange is created
|
||||
- **THEN** the notification entry/list SHALL display the notice
|
||||
- **AND** the client SHALL not call a separate marketing, ERP, or salesperson notification endpoint
|
||||
|
||||
### Requirement: Viewing a notification SHALL support marking it read
|
||||
|
||||
When the user views or activates a notification, the client SHALL call `PUT /api/c/v1/notifications/{id}/read` for that notification and reconcile the local unread count after a successful response. Repeated read actions SHALL remain safe according to the existing notification API contract.
|
||||
|
||||
#### Scenario: User reads an unread notice
|
||||
|
||||
- **WHEN** the user opens or advances to an unread expiry or exchange notification
|
||||
- **THEN** the client SHALL mark that notification read through the existing endpoint
|
||||
- **AND** the badge/list state SHALL reflect the read result
|
||||
|
||||
#### Scenario: Notification read request fails
|
||||
|
||||
- **WHEN** marking a notification read fails
|
||||
- **THEN** the client SHALL retain the unread state or refresh it from the backend
|
||||
- **AND** the failure SHALL not prevent the user from viewing other asset or notification content
|
||||
|
||||
### Requirement: Multi-notification popup SHALL communicate horizontal navigation
|
||||
|
||||
When the homepage notification popup contains more than one notification, the client SHALL make the horizontal swipe interaction visible to the user and SHALL retain the current-position indicator.
|
||||
|
||||
#### Scenario: User opens multiple unread notifications
|
||||
|
||||
- **WHEN** the homepage popup contains two or more notifications
|
||||
- **THEN** the popup SHALL display an explicit `左右滑动切换通知` hint
|
||||
- **AND** the user SHALL be able to move between notifications by swiping horizontally
|
||||
- **AND** the popup SHALL display the current notification position
|
||||
@@ -0,0 +1,59 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Discontinued packages SHALL remain renewable only for eligible existing customers
|
||||
|
||||
The H5/C client SHALL keep discontinued packages out of the ordinary package catalog shown to new customers and agents. An eligible existing customer SHALL be able to use a valid `current_package_id` from asset info or `package_ids` from a historical order to continue a package renewal.
|
||||
|
||||
#### Scenario: New customer opens the package catalog
|
||||
|
||||
- **WHEN** the package catalog contains a discontinued package that is not the customer's current package
|
||||
- **THEN** the client SHALL not display that package as an ordinary purchase option
|
||||
|
||||
#### Scenario: Existing customer is using a discontinued current package
|
||||
|
||||
- **WHEN** asset info returns a discontinued package through `current_package_id`
|
||||
- **THEN** the client SHALL expose that ID only in the eligible renewal context
|
||||
- **AND** the client SHALL allow the customer to continue through the existing standard order/payment flow without navigating to the package catalog
|
||||
|
||||
#### Scenario: Existing customer renews from a historical order
|
||||
|
||||
- **WHEN** a historical order returns one or more valid `package_ids`
|
||||
- **THEN** the client SHALL use those IDs for the renewal selection when the order-list renewal entry is used
|
||||
- **AND** the client SHALL preserve the historical order data unchanged
|
||||
|
||||
### Requirement: Renewal SHALL reuse the standard order creation contract
|
||||
|
||||
The client SHALL create a renewal by calling `POST /api/c/v1/orders/create` with the selected package ID or IDs, the asset `identifier`, and a currently allowed `payment_method`. It SHALL not invent or call a dedicated discontinued-package renewal endpoint.
|
||||
|
||||
#### Scenario: Submit a discontinued-package renewal
|
||||
|
||||
- **WHEN** an eligible customer confirms a renewal with an allowed payment method
|
||||
- **THEN** the client SHALL submit the selected package IDs, identifier, and payment method to the standard order endpoint
|
||||
- **AND** the client SHALL follow the standard payment response handling
|
||||
|
||||
#### Scenario: No eligible package ID is available
|
||||
|
||||
- **WHEN** neither asset info nor the relevant historical order supplies a valid package ID
|
||||
- **THEN** the client SHALL show that renewal is unavailable
|
||||
- **AND** the client SHALL not guess an ID from the ordinary package catalog
|
||||
|
||||
### Requirement: Renewal buttons SHALL be limited to the homepage and order list
|
||||
|
||||
The client SHALL show an `立即续费` button in the homepage asset summary and in every order card in the order list. The client SHALL NOT show a renewal button in the order detail page, package catalog, package-order page, or other pages.
|
||||
|
||||
#### Scenario: Customer renews from the homepage
|
||||
|
||||
- **WHEN** the homepage asset summary is displayed
|
||||
- **THEN** the client SHALL show an `立即续费` button
|
||||
- **AND** tapping it SHALL directly start the standard order/payment flow for the asset's current package ID when one is available
|
||||
|
||||
#### Scenario: Customer renews from an order card
|
||||
|
||||
- **WHEN** an order is displayed in the order list
|
||||
- **THEN** the client SHALL show an `立即续费` button for that order
|
||||
- **AND** tapping it SHALL directly start the standard order/payment flow for that order's package IDs without navigating to the package-order page
|
||||
|
||||
#### Scenario: Customer views another page
|
||||
|
||||
- **WHEN** the customer views any page other than the homepage or order list
|
||||
- **THEN** the client SHALL NOT show an `立即续费` button
|
||||
@@ -0,0 +1,47 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Order views SHALL display backend role and asset snapshots
|
||||
|
||||
The H5/C client SHALL use `GET /api/c/v1/orders` and `GET /api/c/v1/orders/{id}` as the source of truth for order display. Order list and detail views SHALL display the backend `purchase_role` and `asset_identifier` values when present.
|
||||
|
||||
#### Scenario: Order includes a purchase role
|
||||
|
||||
- **WHEN** an order response returns `purchase_role`
|
||||
- **THEN** the order list and detail view SHALL render that role
|
||||
- **AND** the client SHALL not infer the role from the current user or asset type
|
||||
|
||||
#### Scenario: Card order includes an asset identifier
|
||||
|
||||
- **WHEN** a card order returns an `asset_identifier` snapshot
|
||||
- **THEN** the client SHALL display the returned card identifier, such as ICCID, without replacing it with a current asset lookup
|
||||
|
||||
### Requirement: Device order identifiers SHALL prefer virtual number over IMEI
|
||||
|
||||
For device orders, the client SHALL display `virtual_no`/`VirtualNo` when it is non-empty, and SHALL use `imei` only when the virtual number is empty. The client SHALL not use `sn` as a substitute for the order asset identifier.
|
||||
|
||||
#### Scenario: Device has a virtual number
|
||||
|
||||
- **WHEN** a device order response contains a non-empty virtual number and an IMEI
|
||||
- **THEN** the client SHALL display the virtual number
|
||||
- **AND** the client SHALL not display SN as the order device identifier
|
||||
|
||||
#### Scenario: Device virtual number is empty
|
||||
|
||||
- **WHEN** a device order response has an empty virtual number and a non-empty IMEI
|
||||
- **THEN** the client SHALL display the IMEI
|
||||
|
||||
#### Scenario: Historical identifier data is empty
|
||||
|
||||
- **WHEN** both the virtual number and IMEI are absent or empty
|
||||
- **THEN** the client SHALL show the existing empty placeholder
|
||||
- **AND** the client SHALL not fabricate an identifier from SN or a new asset-info request
|
||||
|
||||
### Requirement: Order amounts SHALL use the common currency display rule
|
||||
|
||||
Order list and detail views SHALL display `total_amount` and package prices converted from integer cents to yuan while preserving the integer-cent values in API requests and response state.
|
||||
|
||||
#### Scenario: Display an order amount
|
||||
|
||||
- **WHEN** an order response returns an amount in cents
|
||||
- **THEN** the client SHALL render the corresponding yuan value
|
||||
- **AND** the client SHALL not expose the raw cent value as the user-facing amount
|
||||
51
openspec/changes/update-july-h5-c-iteration/tasks.md
Normal file
51
openspec/changes/update-july-h5-c-iteration/tasks.md
Normal file
@@ -0,0 +1,51 @@
|
||||
## 1. API adapters and shared rules
|
||||
|
||||
- [x] 1.1 Update asset/auth adapters and login handling for `verify-asset` business failures before asset-token persistence
|
||||
- [x] 1.2 Normalize asset response fields for real-name policy/status, allowed payment methods, expiry estimate, and device/card identifiers
|
||||
- [x] 1.3 Update order and wallet adapters so every create request includes the selected `payment_method`, and only WeChat requests include `app_type`
|
||||
- [x] 1.4 Preserve cent-based request payloads and provide one shared yuan display formatter for amounts
|
||||
- [x] 1.5 Confirm notification adapter parameters and response handling against the pending `add-personal-notifications` change
|
||||
|
||||
## 2. Login and asset state
|
||||
|
||||
- [x] 2.1 Show the backend business error and stop the current login flow when C-end login is forbidden
|
||||
- [x] 2.2 Drive real-name prompts, status labels, and order gates from `effective_realname_policy`, `realname_required`, and `real_name_status`
|
||||
- [x] 2.3 Remove local card/device real-name policy inference while preserving the existing real-name link flow
|
||||
- [x] 2.4 Replace current-package-only expiry display with `estimated_final_expires_at` and apply backend expiry status/highlight fields
|
||||
- [x] 2.5 Gate homepage and operator-switch real-name entries for `after_order` using device card real-name states and pending/active package history
|
||||
|
||||
## 3. Package purchase and renewal
|
||||
|
||||
- [x] 3.1 Render payment options from `allowed_payment_methods` on package purchase and prevent unavailable methods from being selected
|
||||
- [x] 3.2 Keep discontinued packages out of the ordinary package catalog for new customers and agents
|
||||
- [x] 3.3 Add the eligible existing-customer renewal path using `current_package_id` or historical `package_ids`
|
||||
- [x] 3.4 Reuse `POST /api/c/v1/orders/create` for renewal and preserve selected identifier, package IDs, payment method, and existing payment result handling
|
||||
- [x] 3.5 Show `立即续费` only on the homepage asset summary and every order-list card, with direct order/payment handling
|
||||
|
||||
## 4. Wallet recharge and payment completion
|
||||
|
||||
- [x] 4.1 Call `/api/c/v1/wallet/recharge-check` before recharge and enforce backend force-recharge/min/max guidance in the UI
|
||||
- [x] 4.2 Render recharge payment methods from the recharge-check response and submit the selected method
|
||||
- [x] 4.3 Preserve existing WeChat `pay_config`, Alipay/other `payment_link`, wallet payment, and submit-guard behaviors
|
||||
- [x] 4.4 Refresh and confirm order/recharge status from the backend after payment return or link completion
|
||||
|
||||
## 5. Customer notifications
|
||||
|
||||
- [x] 5.1 Refresh unread count at the homepage or notification entry using `/api/c/v1/notifications/unread-count`
|
||||
- [x] 5.2 Display expiry and exchange notifications from `/api/c/v1/notifications`, including backend severity/expiry level
|
||||
- [x] 5.3 Mark an item read with `/api/c/v1/notifications/{id}/read` when the user views or activates it, then reconcile the unread badge
|
||||
- [x] 5.4 Keep notification failures non-blocking for asset loading and do not add WeCom, marketing, or ERP calls
|
||||
|
||||
## 6. Order display
|
||||
|
||||
- [x] 6.1 Render `purchase_role` from order list/detail responses
|
||||
- [x] 6.2 Render `asset_identifier` snapshots, using device `virtual_no` first and `imei` second, never SN as a substitute
|
||||
- [x] 6.3 Preserve blank historical data as an empty-state placeholder and format order amounts from cents to yuan
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- [ ] 7.1 Add or update tests for login denial, all real-name policies, backend payment-method variations, and WeChat `app_type`
|
||||
- [ ] 7.2 Add or update tests for force recharge, discontinued-package renewal, expiry estimate states, notification priority/read flow, and order identifier fallback
|
||||
- [x] 7.3 Run the H5 compiler build and repository consistency checks; manual API fixture verification remains an integration follow-up
|
||||
|
||||
> Note: This H5 repository has no test script or test suite. Tasks 7.1 and 7.2 remain unchecked and require adding a test harness or backend/API fixture environment before they can be completed.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 第 1 批 H5 对接实施与验证记录
|
||||
|
||||
2026-10-13:客户端功能已实现。仅对接 `/api/c/v1`;不处理代理注册、密码规则和后台接口。真实微信授权、短信绑定及后端 A/B 资产通知范围尚需测试环境联调,未部署或提交代码。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 客户手机号、资产是否绑号、号码归属、绑定提示分别保存;登录字段按当前令牌和资产隔离,退出/过期/切换时清理,旧 phoneBindStatus 不再作为权威状态。
|
||||
- 四种绑定模式使用原 bind-phone 入参;资产首次绑号可输入与客户号不同的号码,1192 提示管理员并禁用当前提交,1184 保留后端错误。按用户最新确认,强制绑定成功保留有效 token 和资产标识,直接进入首页,不再要求重登。
|
||||
- 客户换号只更新后端确认的客户号码,不改资产关联;归属事实失效,下一次真实登录重新确认。换号的原号来源不再放进 URL。
|
||||
- asset/info 的共享返回入口接收一次性临期提醒,首页按最新要求使用居中、可关闭弹窗,已移除旧横幅组件。0 天正常显示,使用服务器等级和上海时区;与风险换货/运营候选、普通通知依次展示。绑定页预填收到的提醒留在当前会话,进入首页展示。
|
||||
- 旧首页通知弹窗排除临期项,通知中心保留。全部已读按用户 2026-10-14 最新确认恢复为 PUT,单条已读也保持 PUT;成功后重新读取列表和计数,失败不乐观清空,延迟响应不能覆盖新的资产上下文。
|
||||
- 临期弹窗不再展示等级文字标签,仅使用等级配色;新增“立即续费”,先关闭提醒再打开首页原有续费窗口,复用当前套餐 ID/名称/价格和全部原有支付校验,准备和展示期间暂停其他通知。
|
||||
- 通知卡片移除左侧未读强调边框和小圆点,标题右侧显示“未读/已读”小按钮。点击卡片或按钮成功标记后更新,失败仍显示未读。
|
||||
|
||||
## 验证证据
|
||||
|
||||
| 证据 | 结果 | 说明 |
|
||||
|---|---|---|
|
||||
| `npm run test:h5-integration` | 19/19 通过 | 实际 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 / 320×640 | 通过 | 居中临期弹窗、0 天/上海时间、关闭、通知标题右侧未读按钮、点击卡片变已读;此前绑定表单及 1192 检查保留,无页面运行错误 |
|
||||
|
||||
测试文件:[tests/october-h5.test.mjs](../../../tests/october-h5.test.mjs)。覆盖绑定模式/成功后的分类更新、登录提示开关、状态持久化/隔离、换号不迁移资产、1192/1184、强制绑定直接进入首页且 token/客户信息/提醒保留、预填失败、共享接收/并发、0~15 天/上海边界、旧弹窗过滤、PUT 已读请求、通知失败与切换、模拟 A/B 全部已读、401 状态清理以及临期弹窗续费复用/队列暂停。
|
||||
|
||||
本次修订删除了绑定成功后的登录态清理和“绑定手机号成功,请重新登录”提示,替代旧手动重登提案;真正的退出或 401 登录过期处理不变。
|
||||
|
||||
最新界面修订:`ExpiryReminderPopup.vue` 替代横幅;候选请求结束后才消费临期弹窗,普通通知显示时收到临期提醒会排队。候选组件卸载后的延迟响应不再影响新页面。`notifications.vue` 标题右侧使用阅读状态按钮,原标记接口不变。
|
||||
|
||||
本地渲染复验:先完成生产构建,再运行以下命令。使用独立浏览器会话和虚构号码,所有业务接口改写到本地模拟服务。
|
||||
|
||||
```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 停止服务。该模拟接口故意重复返回提醒用于检查前端防重复,不能证明后端每日一次规则。
|
||||
|
||||
需要复验通知按钮时,在启动服务的终端先设置 `$env:H5_MOCK_NOTIFICATIONS='1'`;进入首页关闭临期弹窗,再点击站内通知。模拟通知初始为未读,点击卡片后服务端模拟状态变已读。
|
||||
|
||||
## 仍需真实联调
|
||||
|
||||
1. 微信公众号真实登录应返回三个布尔字段;尤其验证客户无号/资产有号时只给客户绑定,以及客户已有号时资产可绑定不同号码。
|
||||
2. 确认 asset/info.bound_phone 仍表示当前客户手机号,与登录布尔事实区分;换号后资产号码保持原值。若实际响应与对接文档冲突,先确认契约,不猜测接口。
|
||||
3. 同客户 A/B 两个临期资产分别扫码进入,确认服务器每天只返回一次、临期列表/未读数仅计当前资产,以及 A 上 PUT 全部已读不改变 B 的临期通知。
|
||||
4. 完整回归真实支付、实名认证、套餐订购以及运营/风险换货候选弹窗。本次没有修改这些接口或候选组件,构建成功不替代真实业务验收。
|
||||
|
||||
完成结果已按实际验证范围回写 [tasks.md](tasks.md),真实联调项保留未勾选;不归档历史提案。
|
||||
@@ -0,0 +1,98 @@
|
||||
## 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 | 保持 PUT,不采用此前 POST 约定 |
|
||||
|
||||
## 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 }`,不凭空新增绑定对象参数。
|
||||
|
||||
用户最新确认绑定手机号不会让 token 失效。强制绑定成功后保留 token、identifier 及客户信息,直接进入首页;不清除登录态、不进行自动 OAuth 或要求手动重登。普通绑定成功返回上一页。成功后的状态更新必须对应本次实际绑定对象,不能用“请求成功”统一代表客户号和资产号均已修改。
|
||||
|
||||
强制与普通绑定页均可使用 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 的资产信息展示页,本次不新增资产详情路由。按用户最新要求,移除横幅,使用 `ExpiryReminderPopup` 居中弹窗:等级配色、小型时钟图形、突出显示剩余天数和预计到期时间;不展示 level_name 标签。提供“立即续费”、右上关闭及“我知道了”操作。
|
||||
|
||||
首页按现有风险换货/运营候选 → 临期提醒 → 普通通知的顺序展示,一次只显示一个。asset/info 和候选请求未完成时不消费待展示提醒;通知已显示时才收到临期提醒,则等通知关闭后显示。候选按钮立即导航时保持提醒待展示;候选组件卸载后的异步响应不得回调新页面。临期关闭不再占首页布局,也不因此创建新的业务请求。
|
||||
|
||||
原样保留 `expiry_reminder.remaining_days`、`expires_at`、`level`、`level_name`;使用服务端等级映射 red / purple / pink 的样式,保留文本提示,不能只靠颜色传达紧急程度。`remaining_days=0` 必须正常展示。日期展示遵循 Asia/Shanghai,不能用客户端当前时区重算剩余自然日或等级。
|
||||
|
||||
“立即续费”先关闭临期弹窗且不推进其他通知,再调用与首页按钮相同的 startHomepageRenewal,使用当前套餐 ID、名称和续费价格。续费窗口准备中/可见时暂停其他提醒,关闭后继续排队。无可续费套餐或无支付方式时沿用原提示,不新增下单接口,也不在点击提醒按钮时自动创建订单。
|
||||
|
||||
`assetApi.getInfo` 成功返回处集中接收非空提醒并交给共享状态,同次资产进入期间的并发响应不允许用“未返回字段”的响应覆盖已经接收的提醒。共享状态隔离当前客户上下文与 identifier,避免切换资产/退出后显示旧资产或他人的提醒。提醒到达时若资产信息区尚不可见,应先暂存,待对应资产可见时再展示,不能静默丢弃。
|
||||
|
||||
后端负责“客户 + 资产 + 上海自然日”的每日一次;前端只防止同一次接收被多个组件重复展示,不增加每日定时推送、补发请求或长期缓存复播。关闭/已展示的提醒在当天后续无字段响应时不得重新出现。审核所有 `asset/info` 调用方,特别是绑定前预填及首页 TrafficCard 的请求顺序;绑定成功使用同一 token,提醒无需跨登录传递。真实退出、登录过期或切换上下文仍清理旧提醒。
|
||||
|
||||
临期弹窗展示/关闭不自动标记通知已读:提醒不含 notification_id,不能按标题、日期或列表位置猜测 ID,也不能调用 read-all 消掉新通知。临期通知继续由通知中心管理阅读状态。
|
||||
|
||||
### 5. 通知仅调整临期来源与全部已读方法
|
||||
|
||||
从首页旧通知弹窗来源排除临期项,沿用既有 `category=expiry` 识别方式;不删除通知中心临期项,不改变其他未读通知的顺序和展示。`popup-candidates` 的风险换货/运营链路不受影响。
|
||||
|
||||
列表、未读数保持原入参,不增加文档未定义的 identifier/asset_id;后端基于当前认证资产过滤临期通知,并非所有通知都只属于当前资产。切换资产后刷新列表和计数,不将 A 的未读结果拼接到 B。
|
||||
|
||||
全部已读按用户最新确认使用 PUT,保留通知中心主动触发;失败不提前清除未读状态。成功后以刷新结果对齐本地列表/未读数,不能把其他资产的临期通知置已读。单条已读继续 PUT。风险换货、运营及临期弹窗均不得自动调用全部已读。
|
||||
|
||||
通知卡片保留常规边框,移除左侧未读强调边框和小圆点。标题右侧显示绿色“未读”或灰色“已读”小按钮;点击整张卡片或状态按钮调用现有单条 PUT,成功后显示“已读”并刷新计数,失败保留“未读”。按钮阻止事件冒泡,避免一次点击触发两次标记。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 一次性提醒在任何 asset/info 读取时就可能被后端消费:集中接收并回归首页并发、绑定预填、其他页面进入,不能只给首页 getInfo 的响应加判断。
|
||||
- 客户号码与资产号码不同:服务端布尔事实和客户号分离,历史本地缓存不再覆盖服务端。
|
||||
- 没有最新 YAML:按已确认的新文档实施;如实际契约缺字段或与四场景不符,提供响应结构差异(不含真实号码/令牌)并先询问用户。
|
||||
- 尚未归档的旧提案仍含相反规则:本提案显式声明覆盖范围,不顺带改写或归档历史提案。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 提案批准后,先完成状态拆分及旧缓存失效,再调整绑定/换号行为。
|
||||
2. 加入临期提醒共享接收和居中弹窗及排队显示,排除首页旧临期通知弹窗来源,调整通知阅读状态按钮。
|
||||
3. 保持全部已读为 PUT,回归当前资产的列表与未读数。
|
||||
4. 完成任务中的场景验证及 H5 构建,记录实际结果;不以静态构建代替真实接口联调。
|
||||
5. 需要回退时只回退本次前端改动;服务端新契约不变,不能恢复“客户换号即资产迁移”的旧假设作为兼容策略。
|
||||
|
||||
## Open Questions
|
||||
|
||||
提案范围和优先契约已由用户确认,无需再次决定代理注册或 read-all 方法。实施前仍应检查接口是否实际提供所需字段及一次性提醒跨页面传递条件;只在发现具体矛盾时报告并询问,不擅自扩展接口。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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` 的既有错误处理。
|
||||
- 绑定成功保留有效 token 和资产标识;登录引导的绑定页直接进入首页,不再清除登录态、提示或要求重新登录。普通绑定成功仍返回上一页。
|
||||
- **BREAKING(替代旧前端假设)**:`change-phone` 只修改客户手机号,不更新任何资产手机号关联;停止将新客户手机号写入资产绑定缓存。
|
||||
- 接收 `asset/info.expiry_reminder`,在首页展示居中、可关闭的临期弹窗,显示剩余天数、到期时间及等级配色,不显示等级名称标签;“立即续费”复用首页已有续费流程。无字段时不自行计算或补发提醒。
|
||||
- 统一接收各 `asset/info` 调用返回的一次性提醒,防止首页子组件或其他调用方先获得字段后将其丢弃;不为提醒新增轮询或额外请求。
|
||||
- 首页旧未读通知弹窗不再以临期通知作为弹窗依据;临期通知仍保留在通知中心,其他通知和既有运营/风险换货弹窗不变。
|
||||
- 临期弹窗与风险换货/运营、普通通知弹窗依次展示,不叠加;通知卡片去掉左侧未读边框,标题右侧使用“未读/已读”小按钮,点击卡片或按钮后按单条已读响应更新。
|
||||
- 全部已读保持 `PUT /api/c/v1/notifications/read-all`,以用户 2026-10-14 最新确认覆盖此前 POST 约定;通知列表和未读数沿用现有入参,由后端过滤当前资产的临期通知。
|
||||
|
||||
## Scope and confirmed decisions
|
||||
|
||||
用户确认:不处理代理注册;手机号行为以当前对接说明为准。全部已读以 2026-10-14 最新确认的 `PUT` 为准,不采用此前文档中的 POST。
|
||||
|
||||
后续用户补充确认:绑定手机号不会使 token 失效,绑定成功不得再跳转登录页。本次修订覆盖旧强制绑定后手动重登流程。
|
||||
|
||||
后续界面修订:临期提醒必须使用弹窗而非横幅;通知卡片用标题右侧的小按钮表达“未读/已读”,点击卡片完成已读。
|
||||
|
||||
| 接口 | 本次处理 | 不变部分 |
|
||||
|---|---|---|
|
||||
| `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` | 使用后端当前上下文计数 | 原请求参数、未读入口 |
|
||||
| `PUT /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-post-bind-wechat-login-recheck](../update-post-bind-wechat-login-recheck/proposal.md):根据用户最新确认,替代其“强制绑定后清除 token 并要求手动重登”的约定;改为保留登录态直接进入首页,不改写历史提案。
|
||||
- [update-july-h5-c-iteration](../update-july-h5-c-iteration/proposal.md):仅替代临期通知作为首页弹窗来源的约定,保留通知中心、其他通知和套餐到期信息展示。
|
||||
- [add-personal-notifications](../add-personal-notifications/proposal.md):保持单条/全部已读的 PUT,遵循当前资产临期通知范围及最新卡片样式。
|
||||
- [add-h5-popup-candidate-flow](../add-h5-popup-candidate-flow/proposal.md):保留候选弹窗与单条已读链路;候选结束后再显示临期弹窗,不调用全部已读来关闭任何弹窗。
|
||||
|
||||
技术选择、字段依据及风险见 [design.md](design.md),执行及验收清单见 [tasks.md](tasks.md)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user