Compare commits
190 Commits
ac8578b89a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
727c4126dd | ||
|
|
6916be02da | ||
|
|
68af449610 | ||
|
|
b3fcdba80f | ||
|
|
7a78e05570 | ||
|
|
17e4b84e4c | ||
|
|
427b25cabd | ||
|
|
af1dc68681 | ||
|
|
9c5778b706 | ||
|
|
9797de4202 | ||
|
|
dc3a37f60f | ||
|
|
8bf89dbe57 | ||
|
|
6da9353203 | ||
|
|
a30a689dba | ||
|
|
5113520b46 | ||
|
|
43a13bf863 | ||
|
|
d3a40f0d77 | ||
|
|
181d24a388 | ||
|
|
0be233e0c0 | ||
|
|
1477444599 | ||
|
|
9c56aa7e30 | ||
|
|
7ef8615103 | ||
|
|
1336b86fc2 | ||
|
|
e79d17058a | ||
|
|
ea7e2b7362 | ||
|
|
3f0a04f2ce | ||
|
|
3f792d642a | ||
|
|
eb81a5cb21 | ||
|
|
e9634cd4bf | ||
|
|
7a7d63d327 | ||
|
|
b39dff0293 | ||
|
|
bd9f3e6141 | ||
|
|
5e8ff94b7a | ||
|
|
1db7ea9b7c | ||
|
|
f634af1d65 | ||
|
|
52b9027b4f | ||
|
|
4ce6b8ddc1 | ||
|
|
5fa2b6dacf | ||
|
|
1a407924de | ||
|
|
0b8dd7af00 | ||
| 36a1e94d34 | |||
|
|
243f795e3e | ||
|
|
8bdd3a2d2a | ||
| 8d2b3094c6 | |||
| 26ac4da0ad | |||
| fdfe79fe36 | |||
| 6090fb255a | |||
| 3b501d304b | |||
| b247e1873d | |||
| 038334630e | |||
| 3fd3f9dba0 | |||
| 2ffdba83cc |
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"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
|
||||||
@@ -21,9 +21,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||||
export GIT_SSL_NO_VERIFY=1
|
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 }}
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
|
||||||
- name: 设置镜像标签
|
- name: 设置镜像标签
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
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 -->
|
||||||
13
App.vue
13
App.vue
@@ -5,8 +5,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import "uview-plus/index.scss";
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* 主色 */
|
/* 主色 */
|
||||||
--primary: #0A84FF;
|
--primary: #0A84FF;
|
||||||
@@ -41,7 +39,7 @@
|
|||||||
|
|
||||||
/* 背景色 */
|
/* 背景色 */
|
||||||
--bg-primary: #FFFFFF;
|
--bg-primary: #FFFFFF;
|
||||||
--bg-secondary: #F2F2F7;
|
--bg-secondary: #F8F8F8;
|
||||||
--bg-tertiary: #FFFFFF;
|
--bg-tertiary: #FFFFFF;
|
||||||
--bg-grouped: #F2F2F7;
|
--bg-grouped: #F2F2F7;
|
||||||
|
|
||||||
@@ -95,7 +93,8 @@
|
|||||||
|
|
||||||
page {
|
page {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
// min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -105,6 +104,12 @@
|
|||||||
padding: 30rpx 20rpx;
|
padding: 30rpx 20rpx;
|
||||||
max-width: 750rpx;
|
max-width: 750rpx;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pd-20{
|
||||||
|
padding: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
17
Dockerfile
17
Dockerfile
@@ -15,15 +15,23 @@ RUN npm config set registry https://registry.npmmirror.com
|
|||||||
# 复制 package.json(利用 Docker 缓存)
|
# 复制 package.json(利用 Docker 缓存)
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
# 使用 uvm 自动安装最新兼容的 @dcloudio 包版本
|
|
||||||
RUN npx @dcloudio/uvm@latest
|
|
||||||
|
|
||||||
# 安装所有依赖
|
# 安装所有依赖
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# 安装 git (用于获取 commit 信息)
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
# 复制源代码
|
# 复制源代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# 列出文件确认复制成功
|
||||||
|
RUN ls -la
|
||||||
|
|
||||||
|
# 创建 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 生产版本
|
# 构建 H5 生产版本
|
||||||
RUN npm run build:h5
|
RUN npm run build:h5
|
||||||
|
|
||||||
@@ -48,6 +56,9 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
|
|
||||||
# 从构建阶段复制构建产物
|
# 从构建阶段复制构建产物
|
||||||
COPY --from=builder /build/dist/build/h5 /usr/share/nginx/html
|
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
|
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
MP_verify_yU2Z8mp831jh6QX7.txt
Normal file
1
MP_verify_yU2Z8mp831jh6QX7.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
yU2Z8mp831jh6QX7
|
||||||
@@ -5,3 +5,4 @@ export { exchangeApi } from './modules/exchange.js';
|
|||||||
export { orderApi } from './modules/order.js';
|
export { orderApi } from './modules/order.js';
|
||||||
export { realnameApi } from './modules/realname.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';
|
||||||
@@ -3,10 +3,14 @@ import { APP_TYPE } from '@/utils/env.js';
|
|||||||
|
|
||||||
export const orderApi = {
|
export const orderApi = {
|
||||||
getList(identifier, page, page_size, payment_status) {
|
getList(identifier, page, page_size, payment_status) {
|
||||||
|
const data = { identifier, page, page_size };
|
||||||
|
if (payment_status !== undefined && payment_status !== null) {
|
||||||
|
data.payment_status = payment_status;
|
||||||
|
}
|
||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/orders',
|
url: '/api/c/v1/orders',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { identifier, page, page_size, payment_status }
|
data
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -17,11 +21,33 @@ export const orderApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
create(identifier, package_ids) {
|
create(identifier, package_ids, payment_method) {
|
||||||
|
const data = { identifier, package_ids };
|
||||||
|
if (payment_method === 'alipay') {
|
||||||
|
data.payment_method = payment_method;
|
||||||
|
} else {
|
||||||
|
data.app_type = APP_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/orders/create',
|
url: '/api/c/v1/orders/create',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { identifier, package_ids, app_type: APP_TYPE }
|
data
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pay(order_id, payment_method, password) {
|
||||||
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import request from '@/utils/request.js';
|
import request from '@/utils/request.js';
|
||||||
|
|
||||||
export const realnameApi = {
|
export const realnameApi = {
|
||||||
getLink(identifier, iccid) {
|
getLink(identifier, iccid, options = {}) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/realname/link',
|
url: '/api/c/v1/realname/link',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { identifier, iccid }
|
data: { identifier, iccid },
|
||||||
|
...options
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -11,10 +11,15 @@ export const walletApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
recharge(identifier, amount, payment_method) {
|
recharge(identifier, amount, payment_method) {
|
||||||
|
const data = { identifier, amount, payment_method };
|
||||||
|
if (payment_method === 'wechat') {
|
||||||
|
data.app_type = APP_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/wallet/recharge',
|
url: '/api/c/v1/wallet/recharge',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { identifier, amount, payment_method, app_type: APP_TYPE }
|
data
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -34,6 +39,24 @@ export const walletApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 新的充值订单接口
|
||||||
|
getRechargeOrders(identifier, page, page_size, status) {
|
||||||
|
return request({
|
||||||
|
url: '/api/c/v1/recharge-orders',
|
||||||
|
method: 'GET',
|
||||||
|
data: { identifier, page, page_size, status }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取充值订单详情
|
||||||
|
getRechargeOrderDetail(identifier, rechargeOrderId) {
|
||||||
|
return request({
|
||||||
|
url: `/api/c/v1/recharge-orders/${rechargeOrderId}`,
|
||||||
|
method: 'GET',
|
||||||
|
data: { identifier }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getTransactions(identifier, page, page_size, params = {}) {
|
getTransactions(identifier, page, page_size, params = {}) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/wallet/transactions',
|
url: '/api/c/v1/wallet/transactions',
|
||||||
|
|||||||
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 device-status-card">
|
||||||
<view class="card-header mb-md flex-row-sb">
|
<view class="card-header mb-md flex-row-sb">
|
||||||
<view class="title">设备信息</view>
|
<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>
|
||||||
<view class="device-info flex-col-g16">
|
<view class="device-info flex-col-g16">
|
||||||
<view class="info-row flex-row-sb">
|
<view class="info-row flex-row-sb">
|
||||||
<view class="info-label">
|
<view class="info-label">
|
||||||
<view class="subtitle">当前卡: {{ deviceInfo.currentIccid }}</view>
|
<view class="subtitle">当前卡号 {{ deviceInfo.currentIccid }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-values flex-row-g20" @tap="$emit('authentication')">
|
<view class="info-values flex-row-g20" @tap="$emit('authentication')">
|
||||||
<view class="tag-apple" :class="isRealName ? 'tag-success' : 'tag-warning'">
|
<view class="tag-apple" :class="isRealName ? 'tag-success' : 'tag-warning'">
|
||||||
{{ isRealName ? "已实名" : "未实名" }}
|
{{ isRealName ? '已实名' : '未实名' }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row flex-row-sb">
|
<view class="info-row flex-row-sb">
|
||||||
<view class="info-label">
|
<view class="info-label">
|
||||||
<view class="subtitle">到期时间:{{ deviceInfo.expireDate }}</view>
|
<view class="subtitle" v-if="isDevice">IMEI:{{ deviceInfo.imei || '-' }}</view>
|
||||||
</view>
|
<view class="subtitle" v-else>手机号:{{ deviceInfo.bound_phone || '-' }}</view>
|
||||||
<view class="info-values flex-row-g20">
|
|
||||||
<view class="tag-apple tag-primary">{{ deviceInfo.statusStr }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="device-metrics flex-row-sb mt-md">
|
<view class="device-metrics flex-row-sb mt-md">
|
||||||
<view class="metric-item flex-col-center">
|
<view class="metric-item flex-col-center">
|
||||||
<view class="caption mb-xs">信号强度</view>
|
<view class="caption mb-xs">信号判断</view>
|
||||||
<view class="metric-value">{{ getSignalText(deviceInfo.rssi) }}</view>
|
<view class="metric-value">{{ deviceInfo.signal_quality || '暂无数据' }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="metric-item flex-col-center">
|
<view class="metric-item flex-col-center">
|
||||||
<view class="caption mb-xs">设备电量</view>
|
<view class="caption mb-xs">设备电量</view>
|
||||||
@@ -35,9 +33,13 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="metric-item flex-col-center">
|
<view class="metric-item flex-col-center">
|
||||||
<view class="caption mb-xs">连接数量</view>
|
<view class="caption mb-xs">连接数量</view>
|
||||||
<view class="metric-value">{{ deviceInfo.connCnt }} 台</view>
|
<view class="metric-value">{{ deviceInfo.connCnt }}/{{ deviceInfo.max_clients }}</view>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -45,16 +47,15 @@
|
|||||||
defineProps({
|
defineProps({
|
||||||
deviceInfo: { type: Object, default: () => ({}) },
|
deviceInfo: { type: Object, default: () => ({}) },
|
||||||
isRealName: { type: Boolean, default: false },
|
isRealName: { type: Boolean, default: false },
|
||||||
|
isDevice: { type: Boolean, default: true },
|
||||||
opratorList: { type: Array, default: () => [] }
|
opratorList: { type: Array, default: () => [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['authentication']);
|
defineEmits(['authentication']);
|
||||||
|
|
||||||
const getOperator = (iccid) => {
|
const shouldShowSignalReason = (deviceInfo) =>
|
||||||
return '中国电信';
|
['信号较弱', '信号很差', '暂无数据'].includes(deviceInfo?.signal_quality) &&
|
||||||
};
|
!!deviceInfo?.signal_bad_reason;
|
||||||
|
|
||||||
const getSignalText = (rssi) => rssi || '强';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -62,15 +63,45 @@
|
|||||||
.info-row {
|
.info-row {
|
||||||
padding: var(--space-sm) 0;
|
padding: var(--space-sm) 0;
|
||||||
border-bottom: 1rpx solid var(--gray-200);
|
border-bottom: 1rpx solid var(--gray-200);
|
||||||
&:last-child { border-bottom: none; }
|
|
||||||
.info-label { min-width: 200rpx; }
|
&:last-child {
|
||||||
.info-values { flex: 1; justify-content: flex-end; }
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-values {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.device-metrics {
|
.device-metrics {
|
||||||
background: var(--gray-100);
|
background: var(--gray-100);
|
||||||
border-radius: var(--radius-small);
|
border-radius: var(--radius-small);
|
||||||
padding: 20rpx;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<view class="card function-card">
|
<view class="card function-card">
|
||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<view class="title">功能菜单</view>
|
<view class="title">功能菜单</view>
|
||||||
<view class="header-actions">
|
<!-- <view class="header-actions">
|
||||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('sync')">运营商数据同步</button>
|
<button class="btn-apple btn-primary" @tap="$emit('sync')">运营数据同步</button>
|
||||||
</view>
|
</view> -->
|
||||||
</view>
|
</view>
|
||||||
<view class="function-grid">
|
<view class="function-grid">
|
||||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'authentication')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'authentication')"
|
||||||
|
role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
<image src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
||||||
</view>
|
</view>
|
||||||
@@ -15,73 +16,92 @@
|
|||||||
{{ realNameStatus || '实名认证' }}
|
{{ realNameStatus || '实名认证' }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'package-order')" role="button" tabindex="0">
|
<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>
|
||||||
|
</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">
|
<view class="function-icon">
|
||||||
<image src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
<image src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">套餐订购</view>
|
<view class="function-name">套餐订购</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'order-list')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'order-list')" role="button"
|
||||||
|
tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
|
<image src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">我的订单</view>
|
<view class="function-name">我的订单</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'back')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'back')"
|
||||||
|
role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/back.png" mode="aspectFit" alt="后台管理"></image>
|
<image src="/static/back.png" mode="aspectFit" alt="后台管理"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">后台管理</view>
|
<view class="function-name">后台管理</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'switch')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'switch')"
|
||||||
|
role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
<image src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">切换运营商</view>
|
<view class="function-name">切换运营商</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'asset-package')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'asset-package')"
|
||||||
|
role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/shop.png" mode="aspectFit" alt="资产套餐历史"></image>
|
<image src="/static/asset-package-history.png" mode="aspectFit" alt="资产套餐历史"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">资产套餐历史</view>
|
<view class="function-name">资产套餐历史</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'bind')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" @tap="handleBindPhone" role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
|
<image src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">绑定手机号</view>
|
<view :class="['function-name', alreadyBindPhone ? 'text-primary' : 'text-danger']">
|
||||||
|
{{ alreadyBindPhone ? '已绑定' : '未绑定' }}
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'change-phone')" role="button" tabindex="0">
|
</view>
|
||||||
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'change-phone')" role="button"
|
||||||
|
tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
|
<image src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">更换手机号</view>
|
<view class="function-name">更换手机号</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'device-exchange')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive"
|
||||||
|
@tap="$emit('enter', 'device-exchange')" role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/change.png" mode="aspectFit" alt="设备换货"></image>
|
<image src="/static/change.png" mode="aspectFit" alt="换货"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">设备换货</view>
|
<view class="function-name">换货</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'wallet')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'wallet')" role="button"
|
||||||
|
tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/wallet.png" mode="aspectFit" alt="我的钱包"></image>
|
<image src="/static/wallet.png" mode="aspectFit" alt="钱包"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">我的钱包</view>
|
<view class="function-name">{{ walletText }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'restart')"
|
||||||
|
role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/recover.png" mode="aspectFit" alt="重启设备"></image>
|
<image src="/static/restart.png" mode="aspectFit" alt="重启设备"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">重启设备</view>
|
<view class="function-name">重启设备</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'restart')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')"
|
||||||
|
role="button" tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/restart.png" mode="aspectFit" alt="恢复出厂"></image>
|
<image src="/static/recover.png" mode="aspectFit" alt="恢复出厂"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-name">恢复出厂</view>
|
<view class="function-name">恢复出厂</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'out')" role="button" tabindex="0">
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'out')" role="button"
|
||||||
|
tabindex="0">
|
||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
|
<image src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
|
||||||
</view>
|
</view>
|
||||||
@@ -94,13 +114,44 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue';
|
||||||
realNameStatus: { type: String, default: '' },
|
|
||||||
alreadyBindPhone: { type: Boolean, default: false },
|
const props = defineProps({
|
||||||
isDevice: { type: Boolean, default: true }
|
realNameStatus: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
alreadyBindPhone: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isDevice: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
walletBalance: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['enter', 'sync']);
|
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 handleBindPhone = () => {
|
||||||
|
if (props.alreadyBindPhone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('enter', 'bind');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -110,25 +161,33 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-grid {
|
.function-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.function-item {
|
.function-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 20rpx;
|
padding: 20rpx;
|
||||||
|
|
||||||
.function-icon {
|
.function-icon {
|
||||||
width: 120rpx;
|
width: 120rpx;
|
||||||
height: 120rpx;
|
height: 120rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
image { width: 100%; height: 100%; }
|
|
||||||
|
image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.function-name {
|
.function-name {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -139,5 +198,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.text-primary { color: var(--primary) !important; }
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--primary) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,22 +16,22 @@
|
|||||||
<view class="stat-item flex-col-center">
|
<view class="stat-item flex-col-center">
|
||||||
<view class="caption mb-xs">已使用</view>
|
<view class="caption mb-xs">已使用</view>
|
||||||
<view class="stat-value-container">
|
<view class="stat-value-container">
|
||||||
<text class="stat-value">{{ formatDataSize(deviceInfo.totalBytesCnt) }}</text>
|
<text class="stat-value">{{ usedValue }}</text>
|
||||||
<text class="stat-unit">{{ isDevice ? 'GB' : 'MB' }}</text>
|
<text class="stat-unit">{{ usedUnit }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-item flex-col-center">
|
<view class="stat-item flex-col-center">
|
||||||
<view class="caption mb-xs">总流量</view>
|
<view class="caption mb-xs">总流量</view>
|
||||||
<view class="stat-value-container">
|
<view class="stat-value-container">
|
||||||
<text class="stat-value">{{ formatDataSize(deviceInfo.flowSize) }}</text>
|
<text class="stat-value">{{ totalValue }}</text>
|
||||||
<text class="stat-unit">{{ isDevice ? 'GB' : 'MB' }}</text>
|
<text class="stat-unit">{{ totalUnit }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-item flex-col-center">
|
<view class="stat-item flex-col-center">
|
||||||
<view class="caption mb-xs">剩余</view>
|
<view class="caption mb-xs">剩余</view>
|
||||||
<view class="stat-value-container">
|
<view class="stat-value-container">
|
||||||
<text class="stat-value">{{ formatDataSize(deviceInfo.flowSize - deviceInfo.totalBytesCnt) }}</text>
|
<text class="stat-value">{{ remainValue }}</text>
|
||||||
<text class="stat-unit">{{ isDevice ? 'GB' : 'MB' }}</text>
|
<text class="stat-unit">{{ remainUnit }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -39,26 +39,125 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
watch
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
assetApi
|
||||||
|
} from '@/api/index.js';
|
||||||
|
import {
|
||||||
|
useUserStore
|
||||||
|
} from '@/store/index.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
deviceInfo: { type: Object, default: () => ({}) },
|
identifier: {
|
||||||
isDevice: { type: Boolean, default: true }
|
type: String,
|
||||||
});
|
default: ''
|
||||||
|
|
||||||
const usedFlowPercent = computed(() => {
|
|
||||||
if (props.deviceInfo.flowSize && props.deviceInfo.totalBytesCnt) {
|
|
||||||
return (props.deviceInfo.totalBytesCnt / props.deviceInfo.flowSize * 100).toFixed(2);
|
|
||||||
}
|
}
|
||||||
return '0';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDataSize = (size) => {
|
const emit = defineEmits(['packageLoaded']);
|
||||||
if (!size) return '0';
|
|
||||||
const num = parseFloat(size);
|
const userStore = useUserStore();
|
||||||
if (num < 0.01) return '0';
|
|
||||||
return num.toFixed(2);
|
// 流量数据
|
||||||
|
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'
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb >= 1024) {
|
||||||
|
return {
|
||||||
|
value: (mb / 1024).toFixed(2),
|
||||||
|
unit: 'GB'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: mb.toFixed(2),
|
||||||
|
unit: 'MB'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 已使用流量
|
||||||
|
const usedValue = computed(() => formatTraffic(usedMB.value).value);
|
||||||
|
const usedUnit = computed(() => formatTraffic(usedMB.value).unit);
|
||||||
|
|
||||||
|
// 总流量
|
||||||
|
const totalValue = computed(() => formatTraffic(totalMB.value).value);
|
||||||
|
const totalUnit = computed(() => formatTraffic(totalMB.value).unit);
|
||||||
|
|
||||||
|
// 剩余流量
|
||||||
|
const remainValue = computed(() => formatTraffic(remainMB.value).value);
|
||||||
|
const remainUnit = computed(() => formatTraffic(remainMB.value).unit);
|
||||||
|
|
||||||
|
// 使用百分比
|
||||||
|
const usedFlowPercent = computed(() => {
|
||||||
|
if (totalMB.value > 0) {
|
||||||
|
const percent = (usedMB.value / totalMB.value * 100);
|
||||||
|
return Math.min(percent, 100).toFixed(2);
|
||||||
|
}
|
||||||
|
return '0.00';
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
remain
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载套餐流量数据
|
||||||
|
const loadPackageData = async () => {
|
||||||
|
const identifier = props.identifier || userStore.state.identifier;
|
||||||
|
if (!identifier) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await assetApi.getInfo(identifier);
|
||||||
|
|
||||||
|
if (data.current_package_usage_id) {
|
||||||
|
const metrics = getTrafficMetrics(data);
|
||||||
|
usedMB.value = metrics.used;
|
||||||
|
totalMB.value = metrics.total;
|
||||||
|
remainMB.value = metrics.remain;
|
||||||
|
emit('packageLoaded', data);
|
||||||
|
} else {
|
||||||
|
usedMB.value = 0;
|
||||||
|
totalMB.value = 0;
|
||||||
|
remainMB.value = 0;
|
||||||
|
emit('packageLoaded', null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载套餐流量数据失败', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 identifier 变化
|
||||||
|
watch(() => props.identifier || userStore.state.identifier, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
loadPackageData();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
immediate: true
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -67,14 +166,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 4rpx;
|
gap: 4rpx;
|
||||||
.title { font-size: 35rpx; font-weight: 700; margin-right: 5rpx; }
|
|
||||||
|
.title {
|
||||||
|
font-size: 35rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 5rpx;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.traffic-stats {
|
.traffic-stats {
|
||||||
background: var(--gray-100);
|
background: var(--gray-100);
|
||||||
border-radius: var(--radius-small);
|
border-radius: var(--radius-small);
|
||||||
padding: 20rpx;
|
padding: 20rpx;
|
||||||
margin-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
.stat-item { flex: 1; }
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="card user-info-card interactive">
|
<view class="card user-info-card interactive">
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<view class="user-avatar">
|
|
||||||
<image src="https://img1.baidu.com/it/u=2462918877,1866131262&fm=253&fmt=auto&app=138&f=JPEG?w=506&h=500" mode="aspectFill" alt="用户头像" />
|
|
||||||
</view>
|
|
||||||
<view class="user-details flex-col-g8">
|
<view class="user-details flex-col-g8">
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<view class="title">{{ currentCardNo }}</view>
|
<view class="title">{{ currentCardNo || '-' }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="caption">到期时间:{{ deviceInfo.expireDate }}</view>
|
<view class="caption">套餐名称:{{ deviceInfo.packageName || '-' }}</view>
|
||||||
|
<view class="caption">套餐到期时间:{{ deviceInfo.expireDate || '-' }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
|
<view v-if="isDevice" class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
|
||||||
{{ onlineStatus }}
|
{{ onlineStatus }}
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else class="tag-apple" :class="networkStatus == 1 ? 'tag-success' : 'tag-warning'">
|
||||||
|
{{ networkStatus == 1 ? '正常' : '停机' }}
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,21 +22,20 @@
|
|||||||
defineProps({
|
defineProps({
|
||||||
currentCardNo: { type: String, default: '' },
|
currentCardNo: { type: String, default: '' },
|
||||||
deviceInfo: { type: Object, default: () => ({}) },
|
deviceInfo: { type: Object, default: () => ({}) },
|
||||||
onlineStatus: { type: String, default: '离线' }
|
onlineStatus: { type: String, default: '离线' },
|
||||||
|
networkStatus: { type: [String, Number], default: '离线' },
|
||||||
|
isDevice: { type: Boolean, default: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.user-info-card {
|
.user-info-card {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
.user-avatar {
|
|
||||||
width: 80rpx;
|
.user-details {
|
||||||
height: 80rpx;
|
flex: 1;
|
||||||
border-radius: var(--radius-medium);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 2rpx solid var(--border-light);
|
|
||||||
image { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
}
|
}
|
||||||
.user-details { flex: 1; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,4 +44,8 @@
|
|||||||
.wifi-details { flex: 1; }
|
.wifi-details { flex: 1; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group{
|
||||||
|
padding-right: var(--space-md);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
alias /usr/share/nginx/html/;
|
||||||
|
index index.html;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
docs/API.md
21
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:
|
URL:
|
||||||
GET /api/c/v1/asset/info
|
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 参数:
|
Query 参数:
|
||||||
identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
||||||
|
|
||||||
@@ -374,6 +381,11 @@ data:
|
|||||||
URL:
|
URL:
|
||||||
GET /api/c/v1/asset/package-history
|
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 参数:
|
Query 参数:
|
||||||
- identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
- identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
||||||
- page(integer,必填)- 页码
|
- page(integer,必填)- 页码
|
||||||
@@ -408,6 +420,7 @@ GET /api/c/v1/asset/package-history?identifier=1234567890&page=1&page_size=10
|
|||||||
"package_usage_id": 5001,
|
"package_usage_id": 5001,
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
"status": 1,
|
"status": 1,
|
||||||
|
"enable_virtual_data": true,
|
||||||
"status_name": "生效中",
|
"status_name": "生效中",
|
||||||
"usage_type": "single_card",
|
"usage_type": "single_card",
|
||||||
"virtual_limit_mb": 20480,
|
"virtual_limit_mb": 20480,
|
||||||
@@ -448,7 +461,11 @@ data.items[]:
|
|||||||
- virtual_ratio:虚流量比例(real/virtual)
|
- virtual_ratio:虚流量比例(real/virtual)
|
||||||
- virtual_remain_mb:剩余虚流量(MB),按virtual_ratio换算
|
- virtual_remain_mb:剩余虚流量(MB),按virtual_ratio换算
|
||||||
- virtual_used_mb:已用虚流量(MB),按virtual_ratio换算
|
- virtual_used_mb:已用虚流量(MB),按virtual_ratio换算
|
||||||
|
- enable_virtual_data:是否启用虚流量
|
||||||
|
* 如果启用了虚流量那么 本月流量展示的就是 套餐虚流量总量 剩余虚流量 已用虚流量 只是值用这些,已使用,总流量,剩余不要改
|
||||||
|
* 如果没有启用那就是 套餐真流量总量 已用真流量 剩余真流量(自己计算) 然后如果大于或者等于1024MB 转换成 GB
|
||||||
|
* 本月流量哪里需要调用这个接口,带上参数status: 1:生效中 只会有一个生效的
|
||||||
|
*
|
||||||
## 3.3 资产可购套餐列表
|
## 3.3 资产可购套餐列表
|
||||||
|
|
||||||
URL:
|
URL:
|
||||||
|
|||||||
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
626
docs/微信支付集成文档.md
Normal file
626
docs/微信支付集成文档.md
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
# 微信小程序支付集成文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档总结了套餐订购模块中的微信支付完整实现流程,可复用到其他项目中。
|
||||||
|
|
||||||
|
## 一、核心文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
miniprogram/
|
||||||
|
├── utils/
|
||||||
|
│ └── payment.ts # 微信支付工具类
|
||||||
|
├── api/
|
||||||
|
│ └── order.ts # 订单API接口
|
||||||
|
├── types/
|
||||||
|
│ └── api.ts # API类型定义
|
||||||
|
└── pages/
|
||||||
|
└── package/
|
||||||
|
└── package.ts # 套餐订购页面(业务实现)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、支付流程
|
||||||
|
|
||||||
|
### 2.1 标准套餐支付流程(三步走)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 创建订单 → 2. 获取支付参数 → 3. 调起微信支付
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第一步:创建订单
|
||||||
|
|
||||||
|
**接口:** `POST /api/c/v1/orders/create`
|
||||||
|
|
||||||
|
**请求参数:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
app_type: 'miniapp', // 应用类型:miniapp(小程序) | official_account(公众号)
|
||||||
|
identifier: string, // 资产标识符(设备ID/ICCID等)
|
||||||
|
package_ids: number[] // 套餐ID列表
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
order_type: 'package' | 'recharge', // 订单类型
|
||||||
|
order: {
|
||||||
|
order_id: number, // 订单ID(用于后续支付)
|
||||||
|
order_no: string,
|
||||||
|
payment_status: 1, // 1:待支付
|
||||||
|
total_amount: number, // 总金额(分)
|
||||||
|
created_at: string
|
||||||
|
},
|
||||||
|
pay_config?: {...} // 强充订单才返回(见2.2)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第二步:获取支付参数
|
||||||
|
|
||||||
|
**接口:** `POST /api/c/v1/orders/:id/pay`
|
||||||
|
|
||||||
|
**请求参数:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
payment_method: 'wechat', // 支付方式:wechat | wallet
|
||||||
|
app_type: 'miniapp' // 应用类型(微信支付必传)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
payment_method: 'wechat',
|
||||||
|
pay_config: {
|
||||||
|
app_id: string, // 微信AppID
|
||||||
|
timestamp: string, // 时间戳
|
||||||
|
nonce_str: string, // 随机字符串
|
||||||
|
package: string, // 预支付ID,格式:prepay_id=wx...
|
||||||
|
sign_type: string, // 签名类型(RSA/MD5/HMAC-SHA256)
|
||||||
|
pay_sign: string // 支付签名
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第三步:调起微信支付
|
||||||
|
|
||||||
|
使用 `wx.requestPayment()` 调起支付:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
wx.requestPayment({
|
||||||
|
timeStamp: pay_config.timestamp,
|
||||||
|
nonceStr: pay_config.nonce_str,
|
||||||
|
package: pay_config.package,
|
||||||
|
signType: pay_config.sign_type,
|
||||||
|
paySign: pay_config.pay_sign,
|
||||||
|
success: (res) => {
|
||||||
|
// 支付成功
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
// 支付失败或取消
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 强充订单支付流程(一步走)
|
||||||
|
|
||||||
|
当订单返回 `order_type === 'recharge'` 且有 `pay_config` 时,表示强充订单:
|
||||||
|
|
||||||
|
```
|
||||||
|
创建订单(直接返回支付参数) → 调起微信支付
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- 创建订单时已返回完整 `pay_config`
|
||||||
|
- 无需再调用 `/orders/:id/pay` 接口
|
||||||
|
- 直接使用返回的 `pay_config` 调起支付
|
||||||
|
|
||||||
|
**判断逻辑:**
|
||||||
|
```typescript
|
||||||
|
if (orderResult.order_type === 'recharge' && orderResult.pay_config) {
|
||||||
|
// 强充订单,直接支付
|
||||||
|
await WxPayment.payWithCallback(orderResult.pay_config, callbacks);
|
||||||
|
} else {
|
||||||
|
// 普通订单,需要获取支付参数
|
||||||
|
const payResult = await OrderApi.payOrder(orderId, {...});
|
||||||
|
await WxPayment.payWithCallback(payResult.pay_config, callbacks);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、核心代码实现
|
||||||
|
|
||||||
|
### 3.1 微信支付工具类(payment.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 微信支付工具类
|
||||||
|
*/
|
||||||
|
import { Logger } from './logger';
|
||||||
|
|
||||||
|
/** 微信支付配置 */
|
||||||
|
export interface WxPayConfig {
|
||||||
|
timeStamp: string;
|
||||||
|
nonceStr: string;
|
||||||
|
package: string;
|
||||||
|
signType: 'RSA' | 'MD5' | 'HMAC-SHA256';
|
||||||
|
paySign: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付结果 */
|
||||||
|
export interface PaymentResult {
|
||||||
|
success: boolean;
|
||||||
|
errMsg?: string;
|
||||||
|
userCancelled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WxPayment {
|
||||||
|
/**
|
||||||
|
* 发起微信支付
|
||||||
|
* @param payConfig 后端返回的支付配置
|
||||||
|
* @returns 支付结果
|
||||||
|
*/
|
||||||
|
static async pay(payConfig: {
|
||||||
|
app_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce_str: string;
|
||||||
|
package: string;
|
||||||
|
sign_type: string;
|
||||||
|
pay_sign: string;
|
||||||
|
}): Promise<PaymentResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// 1. 验证 package 参数格式
|
||||||
|
if (!payConfig.package || !payConfig.package.startsWith('prepay_id=')) {
|
||||||
|
Logger.error('微信支付参数错误:package 格式不正确', {
|
||||||
|
package: payConfig.package,
|
||||||
|
expected: 'prepay_id=wx...'
|
||||||
|
});
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
errMsg: '支付参数获取失败,请稍后重试'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info('发起微信支付', {
|
||||||
|
...payConfig,
|
||||||
|
pay_sign: payConfig.pay_sign.substring(0, 10) + '...' // 脱敏
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 调用微信支付API
|
||||||
|
wx.requestPayment({
|
||||||
|
timeStamp: payConfig.timestamp,
|
||||||
|
nonceStr: payConfig.nonce_str,
|
||||||
|
package: payConfig.package,
|
||||||
|
signType: payConfig.sign_type as any,
|
||||||
|
paySign: payConfig.pay_sign,
|
||||||
|
success: (res) => {
|
||||||
|
Logger.info('微信支付成功', res);
|
||||||
|
resolve({ success: true });
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
Logger.error('微信支付失败', err);
|
||||||
|
|
||||||
|
// 判断是否为用户取消
|
||||||
|
const userCancelled = err.errMsg?.includes('cancel') ||
|
||||||
|
err.errMsg?.includes('用户取消');
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
errMsg: err.errMsg,
|
||||||
|
userCancelled
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付并使用回调处理结果
|
||||||
|
* @param payConfig 支付配置
|
||||||
|
* @param callbacks 回调函数
|
||||||
|
*/
|
||||||
|
static async payWithCallback(
|
||||||
|
payConfig: {
|
||||||
|
app_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce_str: string;
|
||||||
|
package: string;
|
||||||
|
sign_type: string;
|
||||||
|
pay_sign: string;
|
||||||
|
},
|
||||||
|
callbacks?: {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onFail?: (errMsg: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
): Promise<PaymentResult> {
|
||||||
|
const result = await this.pay(payConfig);
|
||||||
|
|
||||||
|
// 根据结果调用相应回调
|
||||||
|
if (result.success) {
|
||||||
|
callbacks?.onSuccess?.();
|
||||||
|
} else if (result.userCancelled) {
|
||||||
|
callbacks?.onCancel?.();
|
||||||
|
} else {
|
||||||
|
callbacks?.onFail?.(result.errMsg || '支付失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 订单API封装(order.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Request } from '../utils/request';
|
||||||
|
import type {
|
||||||
|
CreateOrderRequest,
|
||||||
|
CreateOrderResponse,
|
||||||
|
PayOrderRequest,
|
||||||
|
PayOrderResponse
|
||||||
|
} from '../types/api';
|
||||||
|
|
||||||
|
export class OrderApi {
|
||||||
|
/**
|
||||||
|
* 创建订单
|
||||||
|
* POST /api/c/v1/orders/create
|
||||||
|
*/
|
||||||
|
static async createOrder(data: CreateOrderRequest): Promise<CreateOrderResponse> {
|
||||||
|
return Request.post<CreateOrderResponse>(
|
||||||
|
'/api/c/v1/orders/create',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单支付
|
||||||
|
* POST /api/c/v1/orders/:id/pay
|
||||||
|
*/
|
||||||
|
static async payOrder(orderId: number, data: PayOrderRequest): Promise<PayOrderResponse> {
|
||||||
|
return Request.post<PayOrderResponse>(
|
||||||
|
`/api/c/v1/orders/${orderId}/pay`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 业务层实现示例(package.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { OrderApi } from '../../api/order';
|
||||||
|
import { WxPayment } from '../../utils/payment';
|
||||||
|
import { Logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建订单并支付(微信支付)
|
||||||
|
*/
|
||||||
|
async createOrderAndPay() {
|
||||||
|
try {
|
||||||
|
const selectedPackage = this.data.packages[this.data.selectedIdx];
|
||||||
|
|
||||||
|
// 第一步:创建订单
|
||||||
|
wx.showLoading({ title: '创建订单...', mask: true });
|
||||||
|
|
||||||
|
const orderResult = await OrderApi.createOrder({
|
||||||
|
app_type: 'miniapp',
|
||||||
|
identifier: this.getIdentifier(),
|
||||||
|
package_ids: [selectedPackage.package_id]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断是否为强充订单
|
||||||
|
if (orderResult.order_type === 'recharge' && orderResult.pay_config) {
|
||||||
|
// 强充订单:直接使用返回的 pay_config 支付
|
||||||
|
|
||||||
|
// 验证 pay_config 有效性
|
||||||
|
if (!orderResult.pay_config.package) {
|
||||||
|
Logger.error('强充订单支付配置无效', {
|
||||||
|
recharge_id: orderResult.recharge?.recharge_id
|
||||||
|
});
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
|
this.closeModal();
|
||||||
|
|
||||||
|
// 调起支付
|
||||||
|
await WxPayment.payWithCallback(
|
||||||
|
orderResult.pay_config,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
this.handlePaySuccess();
|
||||||
|
},
|
||||||
|
onFail: (errMsg) => {
|
||||||
|
wx.showToast({ title: '支付失败', icon: 'none' });
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
wx.showToast({ title: '已取消支付', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通套餐订单:需要获取支付参数
|
||||||
|
const orderId = orderResult.order.order_id;
|
||||||
|
|
||||||
|
// 第二步:获取支付参数
|
||||||
|
wx.showLoading({ title: '准备支付...', mask: true });
|
||||||
|
|
||||||
|
const payResult = await OrderApi.payOrder(orderId, {
|
||||||
|
payment_method: 'wechat',
|
||||||
|
app_type: 'miniapp'
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.info('订单支付接口返回:', {
|
||||||
|
order_id: orderId,
|
||||||
|
has_pay_config: !!payResult.pay_config
|
||||||
|
});
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
|
this.closeModal();
|
||||||
|
|
||||||
|
// 验证 pay_config
|
||||||
|
if (!payResult.pay_config || !payResult.pay_config.package) {
|
||||||
|
Logger.error('订单支付接口返回配置无效', {
|
||||||
|
order_id: orderId
|
||||||
|
});
|
||||||
|
wx.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三步:调起微信支付
|
||||||
|
await WxPayment.payWithCallback(
|
||||||
|
payResult.pay_config,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
this.handlePaySuccess();
|
||||||
|
},
|
||||||
|
onFail: (errMsg) => {
|
||||||
|
wx.showToast({ title: '支付失败', icon: 'none' });
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
wx.showToast({ title: '已取消支付', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
wx.hideLoading();
|
||||||
|
this.closeModal();
|
||||||
|
|
||||||
|
Logger.error('创建订单或支付失败:', error);
|
||||||
|
|
||||||
|
// 特殊错误处理:订单正在创建中
|
||||||
|
if (error.code === 1008) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '订单正在创建中,是否跳转到我的订单页面进行支付?',
|
||||||
|
confirmText: '去支付',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.navigateTo({ url: '/pages/orders/orders' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误提示
|
||||||
|
const errorMsg = error.message || error.msg || '操作失败,请稍后重试';
|
||||||
|
wx.showToast({ title: errorMsg, icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付成功处理
|
||||||
|
*/
|
||||||
|
handlePaySuccess() {
|
||||||
|
const pkg = this.data.packages[this.data.selectedIdx];
|
||||||
|
this.setData({ selectedIdx: -1 });
|
||||||
|
wx.showToast({
|
||||||
|
title: `「${pkg.name}」支付成功`,
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
|
// 可选:刷新数据、跳转页面等
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、类型定义(api.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** 创建订单 - 请求 */
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
app_type?: 'official_account' | 'miniapp'; // 应用类型(强充必传)
|
||||||
|
identifier: string; // 资产标识符
|
||||||
|
package_ids: number[]; // 套餐ID列表
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建订单 - 响应 */
|
||||||
|
export interface CreateOrderResponse {
|
||||||
|
order_type: 'package' | 'recharge'; // 订单类型
|
||||||
|
order: {
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
payment_status: 1 | 2 | 3 | 4; // 1:待支付 2:已支付 3:已取消 4:已退款
|
||||||
|
total_amount: number; // 总金额(分)
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
pay_config?: { // 强充订单才返回
|
||||||
|
app_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce_str: string;
|
||||||
|
package: string; // prepay_id=wx...
|
||||||
|
sign_type: string;
|
||||||
|
pay_sign: string;
|
||||||
|
} | null;
|
||||||
|
recharge?: { // 充值订单信息
|
||||||
|
recharge_id: number;
|
||||||
|
recharge_no: string;
|
||||||
|
amount: number;
|
||||||
|
status: 0 | 1 | 2; // 0:待支付 1:已支付 2:已关闭
|
||||||
|
auto_purchase_status: string;
|
||||||
|
};
|
||||||
|
linked_package_info?: { // 强充关联套餐信息
|
||||||
|
package_names: string[];
|
||||||
|
total_package_amount: number;
|
||||||
|
wallet_credit: number;
|
||||||
|
force_recharge_amount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单支付 - 请求 */
|
||||||
|
export interface PayOrderRequest {
|
||||||
|
payment_method: 'wallet' | 'wechat'; // 支付方式
|
||||||
|
app_type?: 'official_account' | 'miniapp'; // 微信支付必传
|
||||||
|
password?: string; // 钱包支付必传
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单支付 - 响应 */
|
||||||
|
export interface PayOrderResponse {
|
||||||
|
payment_method: 'wallet' | 'wechat';
|
||||||
|
pay_config?: { // 微信支付时返回
|
||||||
|
app_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce_str: string;
|
||||||
|
package: string;
|
||||||
|
sign_type: string;
|
||||||
|
pay_sign: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、关键要点
|
||||||
|
|
||||||
|
### 5.1 支付参数验证
|
||||||
|
|
||||||
|
**必须验证 `package` 参数格式:**
|
||||||
|
```typescript
|
||||||
|
if (!payConfig.package || !payConfig.package.startsWith('prepay_id=')) {
|
||||||
|
// 参数无效,停止支付
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 支付结果判断
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 成功
|
||||||
|
success: (res) => { /* 支付成功 */ }
|
||||||
|
|
||||||
|
// 失败/取消
|
||||||
|
fail: (err) => {
|
||||||
|
// 判断是否用户取消
|
||||||
|
const userCancelled = err.errMsg?.includes('cancel') ||
|
||||||
|
err.errMsg?.includes('用户取消');
|
||||||
|
|
||||||
|
if (userCancelled) {
|
||||||
|
// 用户主动取消
|
||||||
|
} else {
|
||||||
|
// 支付失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 错误处理
|
||||||
|
|
||||||
|
1. **参数验证失败** - 提示用户并停止支付
|
||||||
|
2. **用户取消支付** - 友好提示,不作为错误
|
||||||
|
3. **支付失败** - 提示错误信息
|
||||||
|
4. **订单创建中(1008)** - 引导用户到订单页
|
||||||
|
|
||||||
|
### 5.4 日志记录
|
||||||
|
|
||||||
|
关键节点必须记录日志:
|
||||||
|
- 支付参数(脱敏)
|
||||||
|
- 支付成功/失败
|
||||||
|
- 参数验证失败
|
||||||
|
- 异常情况
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Logger.info('发起微信支付', {
|
||||||
|
...payConfig,
|
||||||
|
pay_sign: payConfig.pay_sign.substring(0, 10) + '...' // 脱敏
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Loading状态管理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建订单前
|
||||||
|
wx.showLoading({ title: '创建订单...', mask: true });
|
||||||
|
|
||||||
|
// 获取支付参数前
|
||||||
|
wx.showLoading({ title: '准备支付...', mask: true });
|
||||||
|
|
||||||
|
// 调起支付前
|
||||||
|
wx.hideLoading(); // 必须关闭,否则遮挡支付界面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 六、常见问题
|
||||||
|
|
||||||
|
### Q1: 为什么有两种支付流程?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- **标准流程(三步)**:普通套餐订购
|
||||||
|
- **快捷流程(一步)**:强充订单,后端已预创建支付参数
|
||||||
|
|
||||||
|
### Q2: package 参数为什么要验证?
|
||||||
|
|
||||||
|
**A:** 后端可能返回空值或格式错误,直接传给微信会导致支付崩溃。必须验证格式为 `prepay_id=wx...`
|
||||||
|
|
||||||
|
### Q3: 如何区分用户取消和支付失败?
|
||||||
|
|
||||||
|
**A:** 检查 `errMsg` 是否包含 `'cancel'` 或 `'用户取消'` 关键字。
|
||||||
|
|
||||||
|
### Q4: 为什么调起支付前要 hideLoading?
|
||||||
|
|
||||||
|
**A:** Loading 遮罩会挡住微信支付界面,导致用户无法操作。
|
||||||
|
|
||||||
|
## 七、快速集成清单
|
||||||
|
|
||||||
|
要在新项目中集成微信支付,需要:
|
||||||
|
|
||||||
|
- [ ] 复制 `utils/payment.ts` 工具类
|
||||||
|
- [ ] 创建订单API接口(`createOrder`、`payOrder`)
|
||||||
|
- [ ] 定义类型文件(`api.ts`)
|
||||||
|
- [ ] 实现业务逻辑(参考 `createOrderAndPay`)
|
||||||
|
- [ ] 添加 Logger 日志工具(可选)
|
||||||
|
- [ ] 处理支付回调(成功、失败、取消)
|
||||||
|
- [ ] 添加错误处理逻辑
|
||||||
|
|
||||||
|
## 八、注意事项
|
||||||
|
|
||||||
|
1. **安全性**
|
||||||
|
- 日志中敏感信息(pay_sign)要脱敏
|
||||||
|
- 永远不要在前端生成支付签名
|
||||||
|
- 所有支付参数由后端返回
|
||||||
|
|
||||||
|
2. **用户体验**
|
||||||
|
- 支付过程中显示 Loading 提示
|
||||||
|
- 调起支付前必须关闭 Loading
|
||||||
|
- 区分取消和失败,给出不同提示
|
||||||
|
- 支付失败时保留订单,允许用户重试
|
||||||
|
|
||||||
|
3. **异常处理**
|
||||||
|
- 参数验证失败时友好提示
|
||||||
|
- 网络错误时允许重试
|
||||||
|
- 订单创建中(1008)引导到订单页
|
||||||
|
- 所有异常都要记录日志
|
||||||
|
|
||||||
|
4. **测试要点**
|
||||||
|
- 正常支付流程
|
||||||
|
- 用户取消支付
|
||||||
|
- 网络异常
|
||||||
|
- 参数异常
|
||||||
|
- 强充订单流程
|
||||||
|
- 普通订单流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本:** v1.0
|
||||||
|
**更新日期:** 2026-04-14
|
||||||
|
**适用范围:** 微信小程序支付
|
||||||
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 文档为准。
|
||||||
80
docs/浅色深色.md
Normal file
80
docs/浅色深色.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
声明 color-scheme
|
||||||
|
有两种方式:
|
||||||
|
|
||||||
|
meta: 在head中声明<meta name="color-scheme" content="light dark">,声明当前页面支持 light 和 dark 两种模式,系统切换到深色模式时,浏览器默认样式也会切换到深色;
|
||||||
|
css:下面的 css 同样可以实现上面 meta 声明的效果
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
注意:此声明并非为页面做自动适配,只影响浏览器默认样式
|
||||||
|
|
||||||
|
更多信息可查阅 W3C 文档 《CSS Color Adjustment Module Level 1》
|
||||||
|
|
||||||
|
通过 CSS 媒体查询
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
颜色较多的情况,建议使用CSS变量对颜色值进行管理
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--nav-bg-color: #F7F7F7;
|
||||||
|
--content-bg-color: #FFFFFF;
|
||||||
|
--font-color: rgba(0,0,0,.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--nav-bg-color: #2F2F2F;
|
||||||
|
--content-bg-color: #2C2C2C;
|
||||||
|
--font-color: rgba(255, 255, 255, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: var(--font-color)
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: var(--nav-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background-color: var(--content-bg-color);
|
||||||
|
}
|
||||||
|
图片适配
|
||||||
|
利用picture+source标签,设置不同模式下的图片 url。
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<!-- 深色模式下的图片 -->
|
||||||
|
<source srcset="dark.jpg" media="(prefers-color-scheme: dark)" />
|
||||||
|
<!-- 默认模式下的图片 -->
|
||||||
|
<img src="light.jpg"/>
|
||||||
|
</picture>
|
||||||
|
JavaScript中判断当前模式&监听模式变化
|
||||||
|
利用的是matchMedia方法,具体用法参考以下例子:
|
||||||
|
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
|
function darkModeHandler() {
|
||||||
|
if (mediaQuery.matches) {
|
||||||
|
console.log('现在是深色模式')
|
||||||
|
} else {
|
||||||
|
console.log('现在是浅色模式')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断当前模式
|
||||||
|
darkModeHandler()
|
||||||
|
// 监听模式变化
|
||||||
|
mediaQuery.addListener(darkModeHandler)
|
||||||
@@ -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" />
|
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
|
||||||
<title></title>
|
<title></title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"/>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"><!--app-html--></div>
|
<div id="app"><!--app-html--></div>
|
||||||
|
|||||||
60
main.js
60
main.js
@@ -1,11 +1,55 @@
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
import uviewPlus from 'uview-plus'
|
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
|
// #ifndef VUE3
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import './uni.promisify.adaptor'
|
import './uni.promisify.adaptor'
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
App.mpType = 'app'
|
App.mpType = 'app'
|
||||||
|
patchSafeSetTabBarStyle()
|
||||||
const app = new Vue({
|
const app = new Vue({
|
||||||
...App
|
...App
|
||||||
})
|
})
|
||||||
@@ -15,8 +59,22 @@ app.$mount()
|
|||||||
// #ifdef VUE3
|
// #ifdef VUE3
|
||||||
import { createSSRApp } from 'vue'
|
import { createSSRApp } from 'vue'
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
|
patchSafeSetTabBarStyle()
|
||||||
const app = createSSRApp(App)
|
const app = createSSRApp(App)
|
||||||
app.use(uviewPlus)
|
// 使用 uview-plus (版本 3.4.0+)
|
||||||
|
app.use(uviewPlus, () => {
|
||||||
|
return {
|
||||||
|
options: {
|
||||||
|
config: {
|
||||||
|
// 默认单位 rpx
|
||||||
|
unit: 'rpx'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
// 可以在这里配置组件默认属性
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name" : "device-voice-h5",
|
"name" : "c-xmot-iot",
|
||||||
"appid" : "__UNI__45F0251",
|
"appid" : "__UNI__45F0251",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
@@ -17,7 +17,10 @@
|
|||||||
"delay" : 0
|
"delay" : 0
|
||||||
},
|
},
|
||||||
/* 模块配置 */
|
/* 模块配置 */
|
||||||
"modules" : {},
|
"modules" : {
|
||||||
|
"Barcode" : {},
|
||||||
|
"Camera" : {}
|
||||||
|
},
|
||||||
/* 应用发布信息 */
|
/* 应用发布信息 */
|
||||||
"distribute" : {
|
"distribute" : {
|
||||||
/* android打包配置 */
|
/* android打包配置 */
|
||||||
@@ -43,7 +46,9 @@
|
|||||||
/* ios打包配置 */
|
/* ios打包配置 */
|
||||||
"ios" : {},
|
"ios" : {},
|
||||||
/* SDK配置 */
|
/* SDK配置 */
|
||||||
"sdkConfigs" : {}
|
"sdkConfigs" : {
|
||||||
|
"payment" : {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/* 快应用特有相关 */
|
/* 快应用特有相关 */
|
||||||
@@ -95,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
|
||||||
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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The project moved mandatory phone-binding enforcement to homepage entry, and then added a one-time homepage bypass after bind success to avoid an immediate redirect loop. A later draft proposed automatically re-running `POST /api/c/v1/auth/wechat-login` after bind success. The required direction is now simpler than both of those approaches: after mandatory bind success, route the user back to the login page, explain that they need to log in again, preserve the identifier they already entered, and let them manually restart login.
|
||||||
|
|
||||||
|
The login page already has behavior that can trigger login automatically from some restored inputs or URL parameters. The new flow therefore needs to preserve the identifier for convenience without accidentally auto-submitting login on the user's behalf.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
- Goals:
|
||||||
|
- Return mandatory bind-success users to the login page
|
||||||
|
- Show a clear re-login prompt after bind success
|
||||||
|
- Preserve the previously entered identifier in the login input
|
||||||
|
- Require the user to manually tap login again
|
||||||
|
- Non-Goals:
|
||||||
|
- Do not add a new backend endpoint
|
||||||
|
- Do not auto-run `POST /api/c/v1/auth/wechat-login` after bind success
|
||||||
|
- Do not change the ordinary bind flow that starts from homepage actions
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Decision: Mandatory bind completion SHALL route to `pages/login/login` instead of continuing directly to `pages/index/index`.
|
||||||
|
- Decision: The system SHALL show a one-time prompt that binding succeeded and the user must log in again.
|
||||||
|
- Decision: The previously entered identifier SHALL be preserved through the bind-success redirect and restored into the login input.
|
||||||
|
- Decision: Restoring the identifier SHALL NOT auto-submit login or auto-trigger the OAuth flow; the user must explicitly tap login.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Keep the one-time homepage bypass: rejected because the post-bind continuation no longer needs to jump directly into the homepage.
|
||||||
|
- Automatically re-run `POST /api/c/v1/auth/wechat-login`: rejected because the requested flow is to require an explicit user-driven re-login.
|
||||||
|
- Pass the identifier through a login-page path that auto-starts login: rejected because the user explicitly wants the identifier preserved without automatic login.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- The client must distinguish between restoring an identifier for display and restoring one for automatic login initiation.
|
||||||
|
- The re-login prompt must be one-time or clearly scoped so it does not keep reappearing on unrelated later visits to the login page.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Remove the automatic post-bind wechat-login continuation plan from the active draft.
|
||||||
|
2. Route mandatory bind success back to `pages/login/login`.
|
||||||
|
3. Preserve the identifier so the login input remains prefilled.
|
||||||
|
4. Show the re-login prompt and require a manual login tap.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which client-side storage path is the best fit for preserving the identifier while avoiding accidental auto-login behavior on the login page?
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Change: Return to login for manual re-login after mandatory phone bind
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The current mandatory bind-success continuation either relies on a homepage bypass workaround or on an automatic follow-up login flow. Both approaches make the post-bind path more fragile than necessary. The desired behavior is simpler: after successful mandatory phone binding, return the user to the login page, tell them they need to log in again, preserve the identifier they already entered, and let the user manually tap login.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Return the user to `pages/login/login` after successful phone binding when the bind page was entered from the mandatory login gate
|
||||||
|
- Show a one-time prompt on the login page that phone binding succeeded and re-login is required
|
||||||
|
- Preserve the identifier the user already entered so the login input stays prefilled after returning
|
||||||
|
- Require the user to manually tap login again instead of auto-starting the login flow
|
||||||
|
- Keep the existing non-mandatory bind completion behavior unchanged
|
||||||
|
- Remove dependence on an automatic post-bind wechat-login recheck for this flow
|
||||||
|
- Preserve the current login and bind-phone API contracts without adding backend endpoints
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `post-bind-manual-relogin`: Define the mandatory post-bind return-to-login and identifier-preservation flow
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `index-phone-bind-gate`: Allow mandatory bind completion from the login gate to return to login instead of homepage
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: `pages/bind/bind.vue`, `pages/login/login.vue`, `pages/index/index.vue`
|
||||||
|
- Affected flow: mandatory phone binding, post-bind routing, login input restoration, manual re-login
|
||||||
|
- No backend API changes
|
||||||
|
- No login or bind-phone response schema changes
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bind completion SHALL remain compatible with homepage entry
|
||||||
|
The system SHALL keep ordinary bind-phone completion compatible with homepage entry while allowing mandatory bind-phone completion from the login gate to return to the login page for a manual re-login.
|
||||||
|
|
||||||
|
#### Scenario: User completes ordinary binding and returns to index
|
||||||
|
- **WHEN** the user completes phone binding successfully after entering the bind page from a later homepage action
|
||||||
|
- **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
|
||||||
|
|
||||||
|
#### Scenario: User completes mandatory binding and returns to login
|
||||||
|
- **WHEN** the user completes phone binding successfully after entering the bind page from the mandatory login gate
|
||||||
|
- **THEN** the system SHALL return the user to `pages/login/login`
|
||||||
|
- **AND** the system SHALL preserve the previously entered identifier for the login input
|
||||||
|
- **AND** the user SHALL manually trigger the next login attempt before the homepage-entry gate is evaluated again
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Mandatory bind completion SHALL return the user to login for manual re-login
|
||||||
|
The system SHALL return the user to `pages/login/login` after successful phone binding when the bind page was entered from the mandatory login gate, and SHALL require an explicit user-driven re-login before continuing.
|
||||||
|
|
||||||
|
#### Scenario: Mandatory bind succeeds and requires manual re-login
|
||||||
|
- **WHEN** the user enters `pages/bind/bind` from the mandatory bind-phone gate
|
||||||
|
- **AND** the user completes phone binding successfully
|
||||||
|
- **THEN** the system SHALL route the user to `pages/login/login` instead of routing directly to `pages/index/index`
|
||||||
|
- **AND** the system SHALL show a prompt that binding succeeded and re-login is required
|
||||||
|
- **AND** the system SHALL wait for the user to explicitly trigger login again
|
||||||
|
|
||||||
|
### Requirement: Login input SHALL preserve the prior identifier without auto-submitting
|
||||||
|
The system SHALL preserve the identifier that the user previously entered before the mandatory bind flow and SHALL restore it into the login input without automatically starting login.
|
||||||
|
|
||||||
|
#### Scenario: Login page restores identifier after mandatory bind success
|
||||||
|
- **WHEN** the user is returned to `pages/login/login` after successful mandatory phone binding
|
||||||
|
- **THEN** the login input SHALL remain populated with the identifier that was entered before the bind flow
|
||||||
|
- **AND** the system SHALL NOT auto-submit login or auto-trigger the OAuth flow only because the identifier was restored
|
||||||
|
- **AND** the user SHALL manually tap the login action to continue
|
||||||
|
|
||||||
|
### Requirement: Mandatory post-bind return SHALL avoid automatic recheck dependencies
|
||||||
|
The system SHALL avoid depending on an automatic post-bind wechat-login recheck for the mandatory bind-success continuation flow.
|
||||||
|
|
||||||
|
#### Scenario: Mandatory bind success does not auto-call wechat-login
|
||||||
|
- **WHEN** the user completes phone binding successfully from the mandatory login gate
|
||||||
|
- **THEN** the system SHALL NOT automatically call `POST /api/c/v1/auth/wechat-login` as the immediate continuation step
|
||||||
|
- **AND** the post-bind continuation SHALL depend on the user re-entering the login flow manually
|
||||||
|
|
||||||
|
### Requirement: Non-mandatory bind completion SHALL keep existing behavior
|
||||||
|
The system SHALL keep the existing non-mandatory bind completion behavior when the bind page was entered from a later homepage action rather than from the mandatory login gate.
|
||||||
|
|
||||||
|
#### Scenario: Ordinary bind completion remains unchanged
|
||||||
|
- **WHEN** the user enters `pages/bind/bind` from a later homepage action rather than the mandatory login gate
|
||||||
|
- **AND** the user completes phone binding successfully
|
||||||
|
- **THEN** the system SHALL keep the current non-mandatory bind completion behavior
|
||||||
|
- **AND** the system SHALL NOT force the user back to `pages/login/login` for that ordinary bind flow
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 1. Post-Bind Manual Re-Login
|
||||||
|
|
||||||
|
- [x] 1.1 Route mandatory bind success to `pages/login/login` instead of continuing directly to `pages/index/index`
|
||||||
|
- [x] 1.2 Show a one-time prompt that the user needs to log in again after successful binding
|
||||||
|
- [x] 1.3 Preserve the previously entered identifier so the login input stays prefilled after returning
|
||||||
|
- [x] 1.4 Ensure the restored identifier does not auto-trigger login and the user must manually tap login
|
||||||
|
|
||||||
|
## 2. Flow Compatibility
|
||||||
|
|
||||||
|
- [x] 2.1 Keep the existing non-mandatory bind completion behavior unchanged
|
||||||
|
- [x] 2.2 Remove dependence on automatic post-bind wechat-login continuation
|
||||||
|
- [x] 2.3 Keep the normal homepage bind gate behavior for later homepage entries
|
||||||
|
- [x] 2.4 Remove dependence on a one-time homepage bypass for mandatory bind success
|
||||||
|
|
||||||
|
## 3. API Contract Preservation
|
||||||
|
|
||||||
|
- [x] 3.1 Keep using the existing login and bind-phone API contracts
|
||||||
|
- [x] 3.2 Avoid adding a backend endpoint or changing current response schemas
|
||||||
|
|
||||||
|
## 4. Regression Verification
|
||||||
|
|
||||||
|
- [x] 4.1 Verify mandatory bind success returns to the login page with the identifier still visible
|
||||||
|
- [x] 4.2 Verify the login page shows the re-login prompt after bind success
|
||||||
|
- [x] 4.3 Verify login does not auto-start until the user clicks login
|
||||||
|
- [x] 4.4 Verify ordinary bind entry from homepage actions still follows the current completion path
|
||||||
24
openspec/changes/update-wallet-display-rules/proposal.md
Normal file
24
openspec/changes/update-wallet-display-rules/proposal.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The homepage wallet entry currently displays the raw wallet balance value returned by the backend, which is cent-based. This causes incorrect user-facing values such as showing `100` instead of `1`. In addition, the wallet recharge order list always renders the auto purchase row, which can show misleading fallback text when the backend does not provide auto purchase data.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Convert the homepage wallet entry balance from cents to yuan before rendering the value inside `钱包(...)`
|
||||||
|
- Keep the homepage wallet entry aligned with user-facing currency expectations instead of exposing the raw cent amount
|
||||||
|
- Render the recharge order `自动购包` field only when the recharge order includes auto purchase status data
|
||||||
|
- Preserve the current auto purchase status text mapping when the field exists, including existing compatibility values
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `wallet-display-rules`: Define wallet-related UI display rules for homepage balance presentation and recharge order field visibility
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: `components/FunctionCard.vue`, `pages/index/index.vue`, `pages/my-wallet/my-wallet.vue`
|
||||||
|
- Affected UI flows: homepage function menu wallet entry, wallet recharge order list
|
||||||
|
- No backend API changes
|
||||||
|
- No payment flow changes
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Homepage wallet entry SHALL display wallet balance in yuan
|
||||||
|
The system SHALL convert the backend wallet balance value from cents to yuan before rendering the balance inside the homepage wallet entry label.
|
||||||
|
|
||||||
|
#### Scenario: Cent-based balance is converted for homepage display
|
||||||
|
- **WHEN** the homepage wallet entry receives a wallet balance value of `100`
|
||||||
|
- **THEN** the system SHALL display the wallet entry label as `钱包 (1)` instead of showing the raw cent value
|
||||||
|
|
||||||
|
#### Scenario: Non-round cent amount is converted for homepage display
|
||||||
|
- **WHEN** the homepage wallet entry receives a cent-based wallet balance that is not a whole yuan amount
|
||||||
|
- **THEN** the system SHALL display the converted yuan value
|
||||||
|
- **AND** the system SHALL NOT display the raw cent integer
|
||||||
|
|
||||||
|
#### Scenario: Zero balance is displayed consistently
|
||||||
|
- **WHEN** the homepage wallet entry receives a wallet balance value of `0`
|
||||||
|
- **THEN** the system SHALL display `钱包 (0)`
|
||||||
|
|
||||||
|
### Requirement: Recharge order auto purchase row SHALL render only when data exists
|
||||||
|
The system SHALL render the `自动购包` row in the wallet recharge order list only when the recharge order includes auto purchase status data.
|
||||||
|
|
||||||
|
#### Scenario: Auto purchase data exists on a recharge order
|
||||||
|
- **WHEN** a recharge order includes `auto_purchase_status`
|
||||||
|
- **THEN** the system SHALL render the `自动购包` row
|
||||||
|
- **AND** the system SHALL display the status text mapped from that value
|
||||||
|
|
||||||
|
#### Scenario: Compatible disabled value still counts as existing data
|
||||||
|
- **WHEN** a recharge order includes `auto_purchase_status` with a value of `0` or `'0'`
|
||||||
|
- **THEN** the system SHALL render the `自动购包` row
|
||||||
|
- **AND** the system SHALL display the compatible disabled status text
|
||||||
|
|
||||||
|
#### Scenario: Auto purchase data is absent on a recharge order
|
||||||
|
- **WHEN** a recharge order does not include `auto_purchase_status`
|
||||||
|
- **THEN** the system SHALL NOT render the `自动购包` row
|
||||||
|
|
||||||
|
### Requirement: Wallet display rules SHALL NOT change API contracts or payment behavior
|
||||||
|
The system SHALL preserve the existing backend data contract, recharge order retrieval flow, payment actions, and wallet detail page amount handling while applying these display rules.
|
||||||
|
|
||||||
|
#### Scenario: Display-only adjustment does not alter business flow
|
||||||
|
- **WHEN** the homepage wallet balance or recharge order list is rendered with the new display rules
|
||||||
|
- **THEN** the system SHALL only change user-facing presentation
|
||||||
|
- **AND** the system SHALL NOT change payment processing or backend request behavior
|
||||||
18
openspec/changes/update-wallet-display-rules/tasks.md
Normal file
18
openspec/changes/update-wallet-display-rules/tasks.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
## 1. Homepage Wallet Balance Display
|
||||||
|
|
||||||
|
- [x] 1.1 Add a display rule for the homepage wallet entry that converts the backend cent amount to yuan before rendering
|
||||||
|
- [x] 1.2 Ensure converted values do not expose the raw cent amount in the `钱包(...)` label
|
||||||
|
- [x] 1.3 Preserve correct display for zero and non-integer yuan values
|
||||||
|
|
||||||
|
## 2. Recharge Order Auto Purchase Field Visibility
|
||||||
|
|
||||||
|
- [x] 2.1 Render the recharge order `自动购包` row only when the recharge order includes auto purchase status data
|
||||||
|
- [x] 2.2 Preserve the existing status text mapping when auto purchase status data exists, including compatible `0` and `1` values
|
||||||
|
- [x] 2.3 Hide the row entirely when auto purchase status data is absent
|
||||||
|
|
||||||
|
## 3. Regression Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Verify a homepage wallet balance of `100` is displayed as `1` in the wallet entry
|
||||||
|
- [x] 3.2 Verify a non-round cent amount is displayed as its yuan value rather than the raw cent integer
|
||||||
|
- [x] 3.3 Verify recharge orders with `auto_purchase_status` render the `自动购包` row
|
||||||
|
- [x] 3.4 Verify recharge orders without `auto_purchase_status` do not render the `自动购包` row
|
||||||
31
openspec/project.md
Normal file
31
openspec/project.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Project Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
[Describe your project's purpose and goals]
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- [List your primary technologies]
|
||||||
|
- [e.g., TypeScript, React, Node.js]
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
[Describe your code style preferences, formatting rules, and naming conventions]
|
||||||
|
|
||||||
|
### Architecture Patterns
|
||||||
|
[Document your architectural decisions and patterns]
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
[Explain your testing approach and requirements]
|
||||||
|
|
||||||
|
### Git Workflow
|
||||||
|
[Describe your branching strategy and commit conventions]
|
||||||
|
|
||||||
|
## Domain Context
|
||||||
|
[Add domain-specific knowledge that AI assistants need to understand]
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
[List any technical, business, or regulatory constraints]
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
[Document key external services, APIs, or systems]
|
||||||
7082
package-lock.json
generated
7082
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -1,21 +1,28 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:h5": "uni",
|
"dev:h5": "uni",
|
||||||
"build:h5": "uni build -p h5"
|
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode development",
|
||||||
|
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dcloudio/types": "*",
|
"@dcloudio/types": "3.4.19",
|
||||||
"@dcloudio/vite-plugin-uni": "*",
|
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"less": "^4.0.0",
|
||||||
"sass": "^1.63.2",
|
"sass": "^1.63.2",
|
||||||
"sass-loader": "^10.4.1",
|
"vite": "5.2.8"
|
||||||
"vite": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dcloudio/uni-app": "*",
|
"@dcloudio/uni-app": "3.0.0-4080720251210001",
|
||||||
"@dcloudio/uni-components": "*",
|
"@dcloudio/uni-components": "3.0.0-4080720251210001",
|
||||||
"@dcloudio/uni-h5": "*",
|
"@dcloudio/uni-h5": "3.0.0-4080720251210001",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "^2.0.11",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"uview-plus": "^3.6.29"
|
"element-china-area-data": "^6.1.0",
|
||||||
|
"jweixin-module": "^1.6.0",
|
||||||
|
"uview-plus": "^3.6.29",
|
||||||
|
"vue": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
pages.json
15
pages.json
@@ -47,7 +47,8 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/bind/bind",
|
"path": "pages/bind/bind",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "绑定手机号"
|
"navigationBarTitleText": "绑定手机号",
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/device-exchange/device-exchange",
|
"path": "pages/device-exchange/device-exchange",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "设备换货"
|
"navigationBarTitleText": "换货"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,6 +75,12 @@
|
|||||||
"navigationBarTitleText": "我的钱包"
|
"navigationBarTitleText": "我的钱包"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/alipay-payment/alipay-payment",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "支付宝支付"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/error/error",
|
"path": "pages/error/error",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -83,8 +90,8 @@
|
|||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarTitleText": "信息查询",
|
"navigationBarTitleText": "设备卡信息查询",
|
||||||
"navigationBarBackgroundColor": "#F8F8F8",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
"backgroundColor": "#F8F8F8"
|
"backgroundColor": "#F8F8F8"
|
||||||
},
|
},
|
||||||
"uniIdRouter": {}
|
"uniIdRouter": {}
|
||||||
|
|||||||
173
pages/alipay-payment/alipay-payment.vue
Normal file
173
pages/alipay-payment/alipay-payment.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container alipay-page">
|
||||||
|
<view class="pay-content">
|
||||||
|
<view class="method-card">
|
||||||
|
<view class="method-header">
|
||||||
|
<view class="method-index">方式一</view>
|
||||||
|
<view class="method-title">浏览器链接支付宝支付</view>
|
||||||
|
</view>
|
||||||
|
<view class="browser-tip">点击右上角三个点,使用浏览器打开</view>
|
||||||
|
<image class="tips-image" src="/static/tips.png" mode="widthFix"></image>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="method-card">
|
||||||
|
<view class="method-header">
|
||||||
|
<view class="method-index">方式二</view>
|
||||||
|
<view class="method-title">支付宝扫码支付</view>
|
||||||
|
</view>
|
||||||
|
<view class="qr-wrap">
|
||||||
|
<u-qrcode v-if="qrUrl" :val="qrUrl" :size="220" unit="px" :show-loading="false"></u-qrcode>
|
||||||
|
</view>
|
||||||
|
<view class="qr-desc">截图保存二维码,打开支付宝扫一扫即可支付</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
import { getPendingAlipayPaymentData } from '@/utils/payment.js';
|
||||||
|
|
||||||
|
const browserUrl = ref('');
|
||||||
|
const qrUrl = ref('');
|
||||||
|
const hasRedirected = ref(false);
|
||||||
|
|
||||||
|
const isInWechat = () => /micromessenger/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
const decodePaymentUrl = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = '='.repeat((4 - normalized.length % 4) % 4);
|
||||||
|
return decodeURIComponent(escape(atob(normalized + padding)));
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQueryParam = (key) => {
|
||||||
|
// #ifdef H5
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get(key) || '';
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return '';
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPayUrlFromQuery = () => {
|
||||||
|
return decodePaymentUrl(getQueryParam('pay64'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQrUrlFromQuery = () => {
|
||||||
|
return decodePaymentUrl(getQueryParam('qr64'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectToAlipayInBrowser = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
if (hasRedirected.value || isInWechat() || !browserUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRedirected.value = true;
|
||||||
|
window.location.href = browserUrl.value;
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPaymentData = () => {
|
||||||
|
const data = getPendingAlipayPaymentData();
|
||||||
|
browserUrl.value = getPayUrlFromQuery() || data?.copyUrl || data?.qrUrl || '';
|
||||||
|
qrUrl.value = getQrUrlFromQuery() || data?.qrUrl || data?.copyUrl || browserUrl.value;
|
||||||
|
redirectToAlipayInBrowser();
|
||||||
|
};
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadPaymentData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.alipay-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 28rpx 24rpx 48rpx;
|
||||||
|
background: linear-gradient(180deg, #eaf4ff 0%, var(--bg-secondary) 42%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-shadow: 0 16rpx 40rpx rgba(10, 132, 255, 0.08);
|
||||||
|
border: 1rpx solid rgba(10, 132, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-desc {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-index {
|
||||||
|
padding: 8rpx 14rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-tip {
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: rgba(10, 132, 255, 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx dashed rgba(10, 132, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -15,30 +15,14 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="package-info">
|
<view class="package-info">
|
||||||
<view class="info-row flex-row-sb">
|
|
||||||
<view class="info-label">套餐类型</view>
|
|
||||||
<view class="info-value">{{ item.package_type === 'formal' ? '普通套餐' : '加油包' }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="info-row flex-row-sb">
|
|
||||||
<view class="info-label">使用类型</view>
|
|
||||||
<view class="info-value">{{ item.usage_type === 'single_card' ? '单卡' : '设备' }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="info-row flex-row-sb">
|
<view class="info-row flex-row-sb">
|
||||||
<view class="info-label">激活时间</view>
|
<view class="info-label">激活时间</view>
|
||||||
<view class="info-value">{{ item.activated_at || '-' }}</view>
|
<view class="info-value">{{ item.activated_at || '-' }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row flex-row-sb">
|
|
||||||
<view class="info-label">创建时间</view>
|
|
||||||
<view class="info-value">{{ item.created_at }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="info-row flex-row-sb">
|
<view class="info-row flex-row-sb">
|
||||||
<view class="info-label">到期时间</view>
|
<view class="info-label">到期时间</view>
|
||||||
<view class="info-value">{{ item.expires_at || '-' }}</view>
|
<view class="info-value">{{ item.expires_at || '-' }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row flex-row-sb">
|
|
||||||
<view class="info-label">优先级</view>
|
|
||||||
<view class="info-value">{{ item.priority }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="divider"></view>
|
<view class="divider"></view>
|
||||||
@@ -47,16 +31,16 @@
|
|||||||
<view class="flow-title">流量信息</view>
|
<view class="flow-title">流量信息</view>
|
||||||
<view class="flow-stats">
|
<view class="flow-stats">
|
||||||
<view class="flow-item">
|
<view class="flow-item">
|
||||||
<view class="flow-label">已用真流量</view>
|
<view class="flow-label">已使用</view>
|
||||||
<view class="flow-value">{{ formatMB(item.data_usage_mb) }}</view>
|
<view class="flow-value">{{ getUsedFlow(item) }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="flow-item">
|
<view class="flow-item">
|
||||||
<view class="flow-label">总量真流量</view>
|
<view class="flow-label">总流量</view>
|
||||||
<view class="flow-value">{{ formatMB(item.data_limit_mb) }}</view>
|
<view class="flow-value">{{ getTotalFlow(item) }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="flow-item">
|
<view class="flow-item">
|
||||||
<view class="flow-label">剩余虚流量</view>
|
<view class="flow-label">剩余</view>
|
||||||
<view class="flow-value">{{ formatMB(item.virtual_remain_mb) }}</view>
|
<view class="flow-value">{{ getRemainFlow(item) }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="progress-section">
|
<view class="progress-section">
|
||||||
@@ -101,16 +85,42 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatMB = (mb) => {
|
const formatMB = (mb) => {
|
||||||
if (!mb) return '0';
|
if (!mb && mb !== 0) return '0 MB';
|
||||||
if (mb >= 1024) {
|
if (mb >= 1024) {
|
||||||
return (mb / 1024).toFixed(2) + ' GB';
|
return (mb / 1024).toFixed(2) + ' GB';
|
||||||
}
|
}
|
||||||
return mb + ' MB';
|
return mb.toFixed(2) + ' MB';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取已使用流量
|
||||||
|
const getUsedFlow = (item) => {
|
||||||
|
if (item.enable_virtual_data) {
|
||||||
|
return formatMB(item.virtual_used_mb || 0);
|
||||||
|
} else {
|
||||||
|
return formatMB(item.real_used_mb || 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取总流量
|
||||||
|
const getTotalFlow = (item) => {
|
||||||
|
return formatMB(item.real_total_mb || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取剩余流量
|
||||||
|
const getRemainFlow = (item) => {
|
||||||
|
const total = item.real_total_mb || 0;
|
||||||
|
const used = item.enable_virtual_data ? (item.virtual_used_mb || 0) : (item.real_used_mb || 0);
|
||||||
|
const remain = Math.max(total - used, 0);
|
||||||
|
return formatMB(remain);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取使用百分比
|
||||||
const getUsagePercent = (item) => {
|
const getUsagePercent = (item) => {
|
||||||
if (!item.data_limit_mb) return 0;
|
const used = item.real_used_mb || 0;
|
||||||
return Math.min((item.data_usage_mb / item.data_limit_mb) * 100, 100).toFixed(2);
|
const total = item.real_total_mb || 0;
|
||||||
|
|
||||||
|
if (!total) return 0;
|
||||||
|
return Math.min((used / total) * 100, 100).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
|
<view v-if="list.length === 0 && !loading" class="empty-state">
|
||||||
|
<view class="empty-icon">📇</view>
|
||||||
|
<view class="empty-title">认证列表为空</view>
|
||||||
|
<view class="empty-desc">当前账号下暂无可实名的卡片</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="card" v-for="item in list" :key="item.iccid">
|
<view class="card" v-for="item in list" :key="item.iccid">
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<view class="logo">
|
<view class="logo">
|
||||||
<image :src="getLogo(item.category).logo" mode="aspectFit"></image>
|
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex-col-g20">
|
<view class="flex-col-g20">
|
||||||
<view class="iccid">ICCID: {{ item.iccid }}</view>
|
<view class="iccid">ICCID: {{ item.iccid }}</view>
|
||||||
<view class="operator">运营商: {{ getLogo(item.category).name }}</view>
|
<view class="operator">运营商: {{ getCarrier(item.carrier_type).name }}</view>
|
||||||
|
<view v-if="item.isDevice" class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -38,34 +45,59 @@
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
let list = reactive([]);
|
let list = reactive([]);
|
||||||
|
let loading = ref(false);
|
||||||
let showIccidModal = ref(false);
|
let showIccidModal = ref(false);
|
||||||
let currentModalIccid = ref('');
|
let currentModalIccid = ref('');
|
||||||
let currentCard = ref(null);
|
let currentCard = ref(null);
|
||||||
|
|
||||||
let opratorList = reactive([
|
const carrierMap = {
|
||||||
{ category: '124', logo: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500', name: '中国电信' },
|
CMCC: '中国移动',
|
||||||
{ category: '125', logo: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500', name: '中国联通' },
|
CUCC: '中国联通',
|
||||||
{ category: '126', logo: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182', name: '中国移动' }
|
CTCC: '中国电信',
|
||||||
]);
|
CBN: '中国广电'
|
||||||
|
};
|
||||||
|
|
||||||
const getLogo = (category) => {
|
const carrierLogoMap = {
|
||||||
const operator = opratorList.find(item => item.category === category);
|
CMCC: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182',
|
||||||
return operator || opratorList[0];
|
CUCC: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||||
|
CTCC: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500',
|
||||||
|
CBN: 'https://img1.baidu.com/it/u=3160680953,3401650303&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCarrier = (carrierType) => {
|
||||||
|
return {
|
||||||
|
name: carrierMap[carrierType] || '-',
|
||||||
|
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCards = async () => {
|
const loadCards = async () => {
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await assetApi.getInfo(userStore.state.identifier);
|
const assetData = await assetApi.getInfo(userStore.state.identifier);
|
||||||
if (data.cards && data.cards.length > 0) {
|
|
||||||
list.splice(0, list.length, ...data.cards.map(card => ({
|
if (assetData.asset_type === 'device') {
|
||||||
|
list.splice(0, list.length, ...(assetData.cards || []).map(card => ({
|
||||||
iccid: card.iccid,
|
iccid: card.iccid,
|
||||||
category: card.carrier_name.includes('电信') ? '124' : card.carrier_name.includes('联通') ? '125' : '126',
|
carrier_type: card.carrier_type,
|
||||||
|
slot_position: card.slot_position,
|
||||||
|
isDevice: true,
|
||||||
isRealName: card.real_name_status === 1
|
isRealName: card.real_name_status === 1
|
||||||
})));
|
})));
|
||||||
|
} else if (assetData.iccid) {
|
||||||
|
list.splice(0, list.length, {
|
||||||
|
iccid: assetData.iccid,
|
||||||
|
carrier_type: assetData.carrier_type,
|
||||||
|
isDevice: false,
|
||||||
|
isRealName: assetData.real_name_status === 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
list.splice(0, list.length);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载卡列表失败', e);
|
console.error('加载卡列表失败', e);
|
||||||
}
|
}
|
||||||
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toReal = async (card) => {
|
const toReal = async (card) => {
|
||||||
@@ -79,22 +111,25 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const doRealName = async () => {
|
const doRealName = async () => {
|
||||||
|
const iccid = currentModalIccid.value;
|
||||||
|
closeModal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await realnameApi.getLink(userStore.state.identifier, currentModalIccid.value);
|
const data = await realnameApi.getLink(userStore.state.identifier, iccid, { showError: false });
|
||||||
if (data.realname_url) {
|
if (data.realname_url) {
|
||||||
uni.setClipboardData({
|
uni.setClipboardData({
|
||||||
data: currentModalIccid.value,
|
data: iccid,
|
||||||
success: () => {
|
success: () => {
|
||||||
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
|
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
closeModal();
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = data.realname_url;
|
window.location.href = data.realname_url;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取实名链接失败', e);
|
console.error('获取实名链接失败', e);
|
||||||
|
uni.showToast({ title: e?.msg || '获取实名链接失败', icon: 'none' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,4 +235,16 @@
|
|||||||
border-radius: var(--radius-small);
|
border-radius: var(--radius-small);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
min-height: 400rpx;
|
||||||
|
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||||
|
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||||
|
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="page">
|
||||||
|
<!-- 自定义导航栏 -->
|
||||||
|
<view v-if="!fromLogin" class="custom-navbar">
|
||||||
|
<view class="navbar-back" @tap="goBack">
|
||||||
|
<image class="back-icon" src="../../static/left.png" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
<view class="navbar-title">绑定手机号</view>
|
||||||
|
<view class="navbar-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="container" :style="{ paddingTop: fromLogin ? '0' : '0' }">
|
||||||
<view class="card flex-col-g20">
|
<view class="card flex-col-g20">
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<label for="">手机号:</label>
|
<label for="">手机号:</label>
|
||||||
@@ -18,13 +28,24 @@
|
|||||||
<view class="btn">
|
<view class="btn">
|
||||||
<up-button type="primary" @tap="bindPhone">绑定手机号</up-button>
|
<up-button type="primary" @tap="bindPhone">绑定手机号</up-button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 如果从登录页面跳转过来,显示退出按钮 -->
|
||||||
|
<view class="btn" v-if="fromLogin">
|
||||||
|
<up-button type="error" @tap="logout">退出</up-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
import { authApi } from '@/api/index.js';
|
import { authApi } from '@/api/index.js';
|
||||||
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
|
||||||
|
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
let bind = reactive({
|
let bind = reactive({
|
||||||
phone: '',
|
phone: '',
|
||||||
@@ -34,6 +55,32 @@
|
|||||||
let cooldown = ref(0);
|
let cooldown = ref(0);
|
||||||
let codeText = ref('获取验证码');
|
let codeText = ref('获取验证码');
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
let fromLogin = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 获取页面参数,判断是否从登录页面跳转过来
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const currentPage = pages[pages.length - 1];
|
||||||
|
const options = currentPage.options;
|
||||||
|
fromLogin.value = options.fromLogin === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
const prepareManualRelogin = () => {
|
||||||
|
const preservedIdentifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
|
||||||
|
|
||||||
|
userStore.clearUser();
|
||||||
|
if (preservedIdentifier) {
|
||||||
|
userStore.setIdentifier(preservedIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.setStorageSync(POST_BIND_RELOGIN_NOTICE_KEY, '1');
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
if (typeof sessionStorage !== 'undefined') {
|
||||||
|
sessionStorage.removeItem('assetToken');
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
const getCode = async () => {
|
const getCode = async () => {
|
||||||
if (!bind.phone) {
|
if (!bind.phone) {
|
||||||
@@ -75,15 +122,99 @@ let timer = null;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await authApi.bindPhone(bind.phone, bind.code);
|
await authApi.bindPhone(bind.phone, bind.code);
|
||||||
|
|
||||||
|
if (fromLogin.value) {
|
||||||
|
prepareManualRelogin();
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uni.showToast({ title: '绑定成功', icon: 'success' });
|
uni.showToast({ title: '绑定成功', icon: 'success' });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// 从其他页面过来的,返回上一页
|
||||||
uni.navigateBack();
|
uni.navigateBack();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('绑定手机号失败', e);
|
console.error('绑定手机号失败', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.clearStorageSync();
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages/index/index'
|
||||||
|
})
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
max-width: 750rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 88rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-bottom: 1rpx solid var(--border-light);
|
||||||
|
padding: 0 24rpx;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.navbar-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 8rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.back-icon {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-placeholder {
|
||||||
|
width: 100rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<view class="label">原手机号</view>
|
<view class="label">原手机号</view>
|
||||||
<up-input placeholder="请输入原手机号" border="surround" v-model="form.oldPhone" />
|
<up-input placeholder="请输入原手机号" border="surround" v-model="form.oldPhone" disabled/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue';
|
import { reactive, ref, onMounted } from 'vue';
|
||||||
import { authApi } from '@/api/index.js';
|
import { authApi } from '@/api/index.js';
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -47,6 +47,17 @@
|
|||||||
newCode: ''
|
newCode: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 获取 URL 参数中的原手机号
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const currentPage = pages[pages.length - 1];
|
||||||
|
const options = currentPage.options;
|
||||||
|
|
||||||
|
if (options.oldPhone) {
|
||||||
|
form.oldPhone = options.oldPhone;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let oldCooldown = ref(0);
|
let oldCooldown = ref(0);
|
||||||
let newCooldown = ref(0);
|
let newCooldown = ref(0);
|
||||||
let oldCodeText = ref('获取验证码');
|
let oldCodeText = ref('获取验证码');
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view v-if="!exchangeData && !loading" class="empty-state">
|
<view v-if="!exchangeData && !loading" class="empty-state">
|
||||||
<view class="empty-icon">🔄</view>
|
<view class="empty-icon"></view>
|
||||||
<view class="empty-title">暂无换货记录</view>
|
<view class="empty-title">暂无换货记录</view>
|
||||||
<view class="empty-desc">当前账号下暂无设备换货记录</view>
|
<view class="empty-desc">当前账号下暂无换货记录</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="exchangeData" class="card exchange-card">
|
<view v-else-if="exchangeData" class="card exchange-card">
|
||||||
<view class="exchange-header flex-row-sb">
|
<view class="exchange-header flex-row-sb">
|
||||||
<view class="exchange-no">{{ exchangeData.exchange_no }}</view>
|
<view class="exchange-no">{{ exchangeData.exchange_no }}</view>
|
||||||
<view class="tag-apple" :class="getStatusClass(exchangeData.status)">{{ exchangeData.status_text }}</view>
|
<view class="tag-apple" :class="getStatusClass(exchangeData.status)">
|
||||||
|
{{ exchangeData.status_text }}
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="exchange-info">
|
<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-row flex-row-sb">
|
||||||
<view class="info-label">换货原因</view>
|
<view class="info-label">换货原因</view>
|
||||||
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
|
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
|
||||||
@@ -21,6 +27,14 @@
|
|||||||
<view class="info-label">申请时间</view>
|
<view class="info-label">申请时间</view>
|
||||||
<view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
|
<view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
|
||||||
</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>
|
||||||
<view class="info-row flex-row-sb" v-if="exchangeData.recipient_name">
|
<view class="info-row flex-row-sb" v-if="exchangeData.recipient_name">
|
||||||
<view class="info-label">收件人</view>
|
<view class="info-label">收件人</view>
|
||||||
<view class="info-value">{{ exchangeData.recipient_name }}</view>
|
<view class="info-value">{{ exchangeData.recipient_name }}</view>
|
||||||
@@ -40,44 +54,122 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<up-popup :show="showPopup" mode="center" @close="showPopup=false">
|
<up-popup :show="showPopup" mode="center" @close="handlePopupClose">
|
||||||
<view class="popup-content">
|
<view class="popup-content">
|
||||||
<view class="popup-title">填写换货信息</view>
|
<view class="popup-title">填写换货信息</view>
|
||||||
|
|
||||||
|
<view class="form-item">
|
||||||
|
<view class="form-label">粘贴收货信息</view>
|
||||||
|
<up-textarea
|
||||||
|
v-model="form.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-item">
|
||||||
<view class="form-label">收件人姓名</view>
|
<view class="form-label">收件人姓名</view>
|
||||||
<up-input placeholder="请输入收件人姓名" border="surround" v-model="form.recipient_name" />
|
<up-input
|
||||||
|
v-model="form.recipient_name"
|
||||||
|
placeholder="请输入收件人姓名"
|
||||||
|
border="surround"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<view class="form-label">收件人电话</view>
|
<view class="form-label">收件人电话</view>
|
||||||
<up-input placeholder="请输入收件人电话" border="surround" v-model="form.recipient_phone" />
|
<up-input
|
||||||
|
v-model="form.recipient_phone"
|
||||||
|
placeholder="请输入收件人电话"
|
||||||
|
border="surround"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<view class="form-label">收货地址</view>
|
<view class="form-label">所在地区</view>
|
||||||
<up-input placeholder="请输入详细收货地址" border="surround" v-model="form.recipient_address" />
|
<view class="region-trigger" @tap="openAreaPicker">
|
||||||
|
<text class="region-trigger-text" :class="{ placeholder: !regionDisplayText }">
|
||||||
|
{{ regionDisplayText || '请选择省 / 市 / 区' }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-item">
|
||||||
|
<view class="form-label">详细地址</view>
|
||||||
|
<up-textarea
|
||||||
|
v-model="form.recipient_address_detail"
|
||||||
|
placeholder="请输入详细收货地址"
|
||||||
|
autoHeight
|
||||||
|
:maxlength="-1"
|
||||||
|
border="surround"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="popup-btn-group">
|
<view class="popup-btn-group">
|
||||||
<button class="btn-apple btn-secondary" @tap="showPopup=false">取消</button>
|
<button class="btn-apple btn-secondary" @tap="handlePopupClose">取消</button>
|
||||||
<button class="btn-apple btn-primary" @tap="submitExchange">提交</button>
|
<button class="btn-apple btn-primary" @tap="submitExchange">提交</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</up-popup>
|
</up-popup>
|
||||||
|
|
||||||
|
<up-picker
|
||||||
|
:show="showAreaPicker"
|
||||||
|
:columns="areaPickerColumns"
|
||||||
|
keyName="label"
|
||||||
|
title="选择所在地区"
|
||||||
|
:defaultIndex="areaPickerDefaultIndex"
|
||||||
|
closeOnClickOverlay
|
||||||
|
@confirm="confirmAreaPicker"
|
||||||
|
@change="handleAreaPickerChange"
|
||||||
|
@cancel="showAreaPicker = false"
|
||||||
|
@close="showAreaPicker = false"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { exchangeApi } from '@/api/index.js';
|
import { exchangeApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import {
|
||||||
|
buildRegionColumns,
|
||||||
|
composeRecipientAddress,
|
||||||
|
getRegionDisplayText,
|
||||||
|
parseSavedRecipientAddress,
|
||||||
|
parseShippingText
|
||||||
|
} from '@/utils/shippingAddress.js';
|
||||||
|
|
||||||
|
const PHONE_REG = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
let exchangeData = reactive(null);
|
const exchangeData = ref(null);
|
||||||
let loading = ref(false);
|
const loading = ref(false);
|
||||||
let showPopup = ref(false);
|
const showPopup = ref(false);
|
||||||
let form = reactive({
|
const showAreaPicker = ref(false);
|
||||||
|
const areaPickerColumns = ref([]);
|
||||||
|
const areaPickerDefaultIndex = ref([0, 0, 0]);
|
||||||
|
const form = reactive({
|
||||||
|
raw_shipping_text: '',
|
||||||
recipient_name: '',
|
recipient_name: '',
|
||||||
recipient_phone: '',
|
recipient_phone: '',
|
||||||
recipient_address: ''
|
recipient_region_codes: [],
|
||||||
|
recipient_address_detail: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const regionDisplayText = computed(() => {
|
||||||
|
return getRegionDisplayText(form.recipient_region_codes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusClass = (status) => {
|
const getStatusClass = (status) => {
|
||||||
@@ -93,59 +185,170 @@
|
|||||||
|
|
||||||
const formatTime = (time) => {
|
const formatTime = (time) => {
|
||||||
if (!time) return '-';
|
if (!time) return '-';
|
||||||
return time.split('T').join(' ').slice(0, 19);
|
return String(time).split('T').join(' ').slice(0, 19);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAreaPickerState = (regionCodes = []) => {
|
||||||
|
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
|
||||||
|
areaPickerColumns.value = columns;
|
||||||
|
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadExchange = async () => {
|
const loadExchange = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await exchangeApi.getPending(userStore.state.identifier);
|
const data = await exchangeApi.getPending(userStore.state.identifier);
|
||||||
if (data) {
|
exchangeData.value = data || null;
|
||||||
exchangeData.splice(0, exchangeData.length, data);
|
} catch (error) {
|
||||||
}
|
console.error('加载换货记录失败', error);
|
||||||
} catch (e) {
|
exchangeData.value = null;
|
||||||
console.error('加载换货记录失败', e);
|
} finally {
|
||||||
}
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopupClose = () => {
|
||||||
|
showPopup.value = false;
|
||||||
|
showAreaPicker.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAreaPicker = () => {
|
||||||
|
syncAreaPickerState(form.recipient_region_codes);
|
||||||
|
showAreaPicker.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPopup = () => {
|
const openPopup = () => {
|
||||||
form.recipient_name = exchangeData?.recipient_name || '';
|
const currentExchange = exchangeData.value || {};
|
||||||
form.recipient_phone = exchangeData?.recipient_phone || '';
|
const { regionCodes, detailAddress } = parseSavedRecipientAddress(
|
||||||
form.recipient_address = exchangeData?.recipient_address || '';
|
currentExchange.recipient_address || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
form.raw_shipping_text = '';
|
||||||
|
form.recipient_name = currentExchange.recipient_name || '';
|
||||||
|
form.recipient_phone = currentExchange.recipient_phone || '';
|
||||||
|
form.recipient_region_codes = regionCodes;
|
||||||
|
form.recipient_address_detail = detailAddress;
|
||||||
|
|
||||||
|
syncAreaPickerState(regionCodes);
|
||||||
showPopup.value = true;
|
showPopup.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitExchange = async () => {
|
const handleAreaPickerChange = (event) => {
|
||||||
if (!form.recipient_name) {
|
const selectedValues = event.value || [];
|
||||||
uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
|
const selectedProvinceCode = selectedValues[0]?.value || '';
|
||||||
return;
|
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) {
|
||||||
|
form.recipient_region_codes = selectedCodes;
|
||||||
|
syncAreaPickerState(selectedCodes);
|
||||||
}
|
}
|
||||||
if (!form.recipient_phone) {
|
showAreaPicker.value = false;
|
||||||
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
|
};
|
||||||
return;
|
|
||||||
}
|
const fillFromPastedText = () => {
|
||||||
if (!form.recipient_address) {
|
if (!form.raw_shipping_text.trim()) {
|
||||||
uni.showToast({ title: '请输入收货地址', icon: 'none' });
|
uni.showToast({ title: '请先粘贴收货信息', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsed = parseShippingText(form.raw_shipping_text);
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
if (parsed.recipient_name) {
|
||||||
|
form.recipient_name = parsed.recipient_name;
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.recipient_phone) {
|
||||||
|
form.recipient_phone = parsed.recipient_phone;
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.regionCodes.length === 3) {
|
||||||
|
form.recipient_region_codes = parsed.regionCodes;
|
||||||
|
syncAreaPickerState(parsed.regionCodes);
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.detailAddress) {
|
||||||
|
form.recipient_address_detail = parsed.detailAddress;
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedCount) {
|
||||||
|
uni.showToast({ title: '未识别到有效收货信息', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({ title: '已识别并填充', icon: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPastedText = () => {
|
||||||
|
form.raw_shipping_text = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitExchange = async () => {
|
||||||
|
if (!form.recipient_name.trim()) {
|
||||||
|
uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.recipient_phone.trim()) {
|
||||||
|
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PHONE_REG.test(form.recipient_phone.trim())) {
|
||||||
|
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.recipient_region_codes.length !== 3) {
|
||||||
|
uni.showToast({ title: '请选择省市区', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.recipient_address_detail.trim()) {
|
||||||
|
uni.showToast({ title: '请输入详细收货地址', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientAddress = composeRecipientAddress(
|
||||||
|
form.recipient_region_codes,
|
||||||
|
form.recipient_address_detail
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await exchangeApi.submitShippingInfo(
|
await exchangeApi.submitShippingInfo(
|
||||||
exchangeData.id,
|
exchangeData.value.id,
|
||||||
form.recipient_name,
|
form.recipient_name.trim(),
|
||||||
form.recipient_phone,
|
form.recipient_phone.trim(),
|
||||||
form.recipient_address
|
recipientAddress
|
||||||
);
|
);
|
||||||
uni.showToast({ title: '提交成功', icon: 'success' });
|
uni.showToast({ title: '提交成功', icon: 'success' });
|
||||||
showPopup.value = false;
|
handlePopupClose();
|
||||||
loadExchange();
|
await loadExchange();
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error('提交换货信息失败', e);
|
console.error('提交换货信息失败', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
syncAreaPickerState();
|
||||||
loadExchange();
|
loadExchange();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -159,24 +362,57 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 120rpx 40rpx;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
|
||||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
.empty-icon {
|
||||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; margin-bottom: 40rpx; }
|
font-size: 120rpx;
|
||||||
.empty-btn { width: 100%; max-width: 400rpx; }
|
margin-bottom: 30rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.exchange-card {
|
.exchange-card {
|
||||||
.exchange-header {
|
.exchange-header {
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
.exchange-no { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
|
|
||||||
|
.exchange-no {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.exchange-info {
|
.exchange-info {
|
||||||
.info-row {
|
.info-row {
|
||||||
padding: var(--space-xs) 0;
|
padding: var(--space-xs) 0;
|
||||||
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
|
|
||||||
.info-value { font-size: 24rpx; color: var(--text-primary); }
|
.info-label {
|
||||||
.address-value { max-width: 400rpx; text-align: right; }
|
font-size: 24rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-value {
|
||||||
|
max-width: 400rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,14 +420,19 @@
|
|||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
padding-top: var(--space-md);
|
padding-top: var(--space-md);
|
||||||
border-top: 1rpx solid var(--gray-200);
|
border-top: 1rpx solid var(--gray-200);
|
||||||
.btn-apple { width: 100%; }
|
|
||||||
|
.btn-apple {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-content {
|
.popup-content {
|
||||||
width: 600rpx;
|
width: 660rpx;
|
||||||
padding: 30rpx;
|
padding: 30rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -199,19 +440,60 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-item {
|
.form-item {
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-helper {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paste-action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
|
||||||
|
.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-btn-group {
|
.popup-btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
.btn-apple { flex: 1; }
|
|
||||||
|
.btn-apple {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus" />
|
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus"
|
||||||
|
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status" />
|
||||||
|
|
||||||
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
|
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
|
||||||
@authentication="enterDetail('authentication')" />
|
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
|
||||||
|
|
||||||
<VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
|
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
|
||||||
@openDatePicker="openDateRangePicker" />
|
@openDatePicker="openDateRangePicker" /> -->
|
||||||
|
|
||||||
<TrafficCard :deviceInfo="deviceInfo" :isDevice="userInfo.isDevice" />
|
<TrafficCard :identifier="currentCardNo" />
|
||||||
|
|
||||||
<WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
|
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
|
||||||
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" />
|
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
|
||||||
|
|
||||||
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi" @copy="copy" />
|
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi" @copy="copy" />
|
||||||
|
|
||||||
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
|
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
|
||||||
:isDevice="userInfo.isDevice" @enter="enterDetail" @sync="onSync">
|
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" @enter="enterDetail" @sync="onSync">
|
||||||
<!-- 修改WIFI弹窗 -->
|
<!-- 修改WIFI弹窗 -->
|
||||||
<up-popup :show="showModifyWifi" mode="center" @close="showModifyWifi=false">
|
<up-popup :show="showModifyWifi" mode="center" @close="showModifyWifi=false">
|
||||||
<view class="wifi-popup">
|
<view class="wifi-popup">
|
||||||
@@ -24,11 +25,11 @@
|
|||||||
<view class="flex-col-g20">
|
<view class="flex-col-g20">
|
||||||
<view class="flex-col-g8">
|
<view class="flex-col-g8">
|
||||||
<label class="caption">名称:</label>
|
<label class="caption">名称:</label>
|
||||||
<up-input placeholder="WIFI名称" border="surround" v-model="wifi_info.ssid" />
|
<up-input placeholder="WIFI名称" class="wifi-input" v-model="wifi_info.ssid" />
|
||||||
</view>
|
</view>
|
||||||
<view class="flex-col-g8">
|
<view class="flex-col-g8">
|
||||||
<label class="caption">密码:</label>
|
<label class="caption">密码:</label>
|
||||||
<up-input placeholder="WIFI密码" border="surround" v-model="wifi_info.pwd" />
|
<up-input placeholder="WIFI密码" class="wifi-input" v-model="wifi_info.pwd" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="btn-group mt-20">
|
<view class="btn-group mt-20">
|
||||||
@@ -112,6 +113,7 @@
|
|||||||
onMounted,
|
onMounted,
|
||||||
computed
|
computed
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import UserInfoCard from '@/components/UserInfoCard.vue';
|
import UserInfoCard from '@/components/UserInfoCard.vue';
|
||||||
import DeviceStatusCard from '@/components/DeviceStatusCard.vue';
|
import DeviceStatusCard from '@/components/DeviceStatusCard.vue';
|
||||||
import VoiceCard from '@/components/VoiceCard.vue';
|
import VoiceCard from '@/components/VoiceCard.vue';
|
||||||
@@ -120,8 +122,13 @@
|
|||||||
import WifiCard from '@/components/WifiCard.vue';
|
import WifiCard from '@/components/WifiCard.vue';
|
||||||
import FunctionCard from '@/components/FunctionCard.vue';
|
import FunctionCard from '@/components/FunctionCard.vue';
|
||||||
import FloatingButton from '@/components/FloatingButton.vue';
|
import FloatingButton from '@/components/FloatingButton.vue';
|
||||||
import { assetApi, deviceApi } from '@/api/index.js';
|
import {
|
||||||
import { useUserStore } from '@/store/index.js';
|
assetApi,
|
||||||
|
deviceApi
|
||||||
|
} from '@/api/index.js';
|
||||||
|
import {
|
||||||
|
useUserStore
|
||||||
|
} from '@/store/index.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
@@ -137,29 +144,39 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let deviceInfo = reactive({
|
let deviceInfo = reactive({
|
||||||
battery: 100,
|
battery: 0,
|
||||||
connect: 0,
|
connect: 0,
|
||||||
statusStr: '正常',
|
network_status: 1,
|
||||||
expireDate: '',
|
status: 1,
|
||||||
currentIccid: '',
|
packageName: '-',
|
||||||
category: '',
|
expireDate: '-',
|
||||||
phone: '',
|
walletBalance: 0,
|
||||||
|
iccid: '-',
|
||||||
|
currentIccid: '-',
|
||||||
|
category: '-',
|
||||||
|
phone: '-',
|
||||||
flowSize: 0,
|
flowSize: 0,
|
||||||
totalBytesCnt: 0,
|
totalBytesCnt: 0,
|
||||||
ssidName: '',
|
ssidName: '-',
|
||||||
ssidPwd: '',
|
ssidPwd: '-',
|
||||||
rssi: '强',
|
signal_quality: '暂无数据',
|
||||||
|
signal_bad_reason: '',
|
||||||
onlineStatus: '0',
|
onlineStatus: '0',
|
||||||
connCnt: 0,
|
connCnt: 0,
|
||||||
run_time: 0,
|
run_time: 0,
|
||||||
last_online_time: '',
|
last_online_time: '-',
|
||||||
lan_ip: '',
|
lan_ip: '-',
|
||||||
kf_url: '',
|
kf_url: '-',
|
||||||
mchList: []
|
mchList: [],
|
||||||
|
imei: '-',
|
||||||
|
max_clients: 1,
|
||||||
|
asset_type: 'device',
|
||||||
|
bound_phone: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
let isRealName = ref(false);
|
let isRealName = ref(false);
|
||||||
let alreadyBindPhone = ref(false);
|
let alreadyBindPhone = ref(false);
|
||||||
|
let boundPhone = ref('');
|
||||||
let realNameStatus = ref('');
|
let realNameStatus = ref('');
|
||||||
let wifi_info = reactive({
|
let wifi_info = reactive({
|
||||||
ssid: '',
|
ssid: '',
|
||||||
@@ -192,6 +209,18 @@
|
|||||||
let showSmsCode = ref(false);
|
let showSmsCode = ref(false);
|
||||||
let smsCodePhone = ref('');
|
let smsCodePhone = ref('');
|
||||||
let smsCode = ref('');
|
let smsCode = ref('');
|
||||||
|
let indexEntryChecking = ref(false);
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return dateStr.split('T')[0] || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCurrentCard = (cards = []) => {
|
||||||
|
if (cards.length === 1) return cards[0];
|
||||||
|
if (cards.length > 1) return cards.find(card => card.is_current) || null;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadAssetInfo = async () => {
|
const loadAssetInfo = async () => {
|
||||||
const identifier = userStore.state.identifier;
|
const identifier = userStore.state.identifier;
|
||||||
@@ -200,37 +229,118 @@
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await assetApi.getInfo(identifier);
|
const data = await assetApi.getInfo(identifier);
|
||||||
userInfo.isDevice = data.asset_type === 'device';
|
|
||||||
currentCardNo.value = data.identifier;
|
|
||||||
deviceInfo.statusStr = data.status === 1 ? '正常' : '禁用';
|
|
||||||
isRealName.value = data.real_name_status === 1;
|
|
||||||
realNameStatus.value = data.real_name_status === 1 ? '已实名' : '未实名';
|
|
||||||
deviceInfo.flowSize = data.package_remain_mb || 0;
|
|
||||||
deviceInfo.totalBytesCnt = data.package_total_mb || 0;
|
|
||||||
deviceInfo.currentIccid = data.identifier;
|
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
userInfo.isDevice = data.asset_type === 'device';
|
||||||
|
currentCardNo.value = data.identifier || '-';
|
||||||
|
deviceInfo.network_status = data.network_status;
|
||||||
|
deviceInfo.status = data.status;
|
||||||
|
deviceInfo.imei = data.imei || '-';
|
||||||
|
deviceInfo.asset_type = data.asset_type || 'device';
|
||||||
|
deviceInfo.bound_phone = data.bound_phone || '';
|
||||||
|
deviceInfo.packageName = data.current_package || '-';
|
||||||
|
deviceInfo.expireDate = formatDate(data.current_package_expires_at);
|
||||||
|
deviceInfo.iccid = data.iccid || '-';
|
||||||
|
deviceInfo.walletBalance = data.wallet_balance ?? 0;
|
||||||
|
const currentCard = resolveCurrentCard(data.cards || []);
|
||||||
|
isRealName.value = !!currentCard?.real_name_at || currentCard?.real_name_status === 1 || data.real_name_status === 1;
|
||||||
|
realNameStatus.value = isRealName.value ? '已实名' : '未实名';
|
||||||
|
boundPhone.value = data.bound_phone || '';
|
||||||
|
alreadyBindPhone.value = !!data.bound_phone;
|
||||||
|
|
||||||
|
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
|
||||||
|
|
||||||
|
// 当前卡信息
|
||||||
|
if (data.cards && data.cards.length > 0) {
|
||||||
|
deviceInfo.currentIccid = currentCard?.iccid || '-';
|
||||||
|
|
||||||
|
// 卡列表
|
||||||
|
deviceInfo.mchList = data.cards.map(card => ({
|
||||||
|
iccidMark: card.iccid || '-',
|
||||||
|
category: card.slot_position,
|
||||||
|
is_current: card.is_current
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
deviceInfo.currentIccid = '-';
|
||||||
|
deviceInfo.mchList = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运营商信息
|
||||||
|
const carrierTypeMap = {
|
||||||
|
CMCC: '中国移动',
|
||||||
|
CUCC: '中国联通',
|
||||||
|
CTCC: '中国电信',
|
||||||
|
CBN: '中国广电'
|
||||||
|
};
|
||||||
|
deviceInfo.carrier_name = (currentCard && carrierTypeMap[currentCard.carrier_type]) || '-';
|
||||||
|
|
||||||
|
// 设备实时信息
|
||||||
if (data.device_realtime) {
|
if (data.device_realtime) {
|
||||||
const rt = data.device_realtime;
|
const rt = data.device_realtime;
|
||||||
deviceInfo.onlineStatus = rt.online_status === 1 ? '1' : '0';
|
deviceInfo.onlineStatus = rt.online_status === 1 ? '1' : '0';
|
||||||
deviceInfo.battery = rt.battery_level || 100;
|
deviceInfo.battery = rt.battery_level ?? 0;
|
||||||
deviceInfo.ssidName = rt.ssid || '';
|
deviceInfo.ssidName = rt.ssid || '-';
|
||||||
deviceInfo.ssidPwd = rt.wifi_password || '';
|
deviceInfo.ssidPwd = rt.wifi_password || '-';
|
||||||
deviceInfo.lan_ip = rt.lan_ip || '';
|
deviceInfo.lan_ip = rt.lan_ip || '-';
|
||||||
deviceInfo.connCnt = rt.client_number || 0;
|
deviceInfo.connCnt = rt.client_number ?? 0;
|
||||||
|
deviceInfo.max_clients = rt.max_clients ?? 1;
|
||||||
deviceInfo.run_time = rt.run_time || 0;
|
deviceInfo.run_time = rt.run_time || 0;
|
||||||
deviceInfo.rssi = rt.rssi || '强';
|
deviceInfo.signal_quality = rt.signal_quality || '暂无数据';
|
||||||
|
deviceInfo.signal_bad_reason = rt.signal_bad_reason || '';
|
||||||
|
} else {
|
||||||
|
// 没有实时信息时设置默认值
|
||||||
|
deviceInfo.onlineStatus = '0';
|
||||||
|
deviceInfo.battery = 0;
|
||||||
|
deviceInfo.ssidName = '-';
|
||||||
|
deviceInfo.ssidPwd = '-';
|
||||||
|
deviceInfo.lan_ip = '-';
|
||||||
|
deviceInfo.connCnt = 0;
|
||||||
|
deviceInfo.max_clients = 1;
|
||||||
|
deviceInfo.run_time = 0;
|
||||||
|
deviceInfo.signal_quality = '暂无数据';
|
||||||
|
deviceInfo.signal_bad_reason = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.cards && data.cards.length > 0) {
|
// 到期时间由 TrafficCard 组件获取套餐信息时一并返回
|
||||||
deviceInfo.mchList = data.cards.map(card => ({
|
return data;
|
||||||
iccidMark: card.iccid,
|
|
||||||
category: card.slot_position
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载资产信息失败', e);
|
console.error('加载资产信息失败', e);
|
||||||
}
|
return null;
|
||||||
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndexEntry = async () => {
|
||||||
|
const token = uni.getStorageSync('token');
|
||||||
|
if (!token) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexEntryChecking.value) return;
|
||||||
|
indexEntryChecking.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await loadAssetInfo();
|
||||||
|
if (data && !data.bound_phone) {
|
||||||
|
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
indexEntryChecking.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理套餐加载完成事件(从 TrafficCard 组件传递过来)
|
||||||
|
const handlePackageLoaded = (activePackage) => {
|
||||||
|
if (activePackage && activePackage.expires_at) {
|
||||||
|
// 格式化到期时间
|
||||||
|
const date = new Date(activePackage.expires_at);
|
||||||
|
deviceInfo.expireDate =
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
deviceInfo.expireDate = '-';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const modifyWifi = () => {
|
const modifyWifi = () => {
|
||||||
@@ -249,26 +359,124 @@
|
|||||||
);
|
);
|
||||||
deviceInfo.ssidName = wifi_info.ssid;
|
deviceInfo.ssidName = wifi_info.ssid;
|
||||||
deviceInfo.ssidPwd = wifi_info.pwd;
|
deviceInfo.ssidPwd = wifi_info.pwd;
|
||||||
uni.showToast({ title: '修改成功', icon: 'success' });
|
uni.showToast({
|
||||||
|
title: '修改成功',
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('修改WiFi失败', e);
|
console.error('修改WiFi失败', e);
|
||||||
}
|
}
|
||||||
showModifyWifi.value = false;
|
showModifyWifi.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectKF = () => uni.showToast({
|
const connectKF = () => {
|
||||||
title: '正在跳转客服',
|
const kfUrl =
|
||||||
icon: 'none'
|
'https://work.weixin.qq.com/kfid/kfc0fd79f27686fb65e?enc_scene=ENCfR9AVui6UfvvxySGVuvaG&scene_param=commonlink';
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = kfUrl;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 微信小程序跳转到企业微信客服
|
||||||
|
wx.openCustomerServiceChat({
|
||||||
|
extInfo: {
|
||||||
|
url: kfUrl
|
||||||
|
},
|
||||||
|
corpId: 'ww4e3b7f9f7c8a9b0c',
|
||||||
|
success: () => {
|
||||||
|
console.log('打开客服会话成功');
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('打开客服会话失败', err);
|
||||||
|
// 失败时使用网页跳转
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/webview/webview?url=${encodeURIComponent(kfUrl)}`
|
||||||
});
|
});
|
||||||
const enterBack = () => uni.showToast({
|
}
|
||||||
title: '正在跳转后台',
|
|
||||||
icon: 'none'
|
|
||||||
});
|
});
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef H5 || MP-WEIXIN
|
||||||
|
// 其他平台,复制链接
|
||||||
|
uni.showModal({
|
||||||
|
title: '联系客服',
|
||||||
|
content: '是否复制客服链接?',
|
||||||
|
confirmText: '复制',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: kfUrl,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '客服链接已复制',
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterBack = async () => {
|
||||||
|
if (!deviceInfo.lan_ip || deviceInfo.lan_ip === '-') {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请将设备开机后, 等待2分钟左右进行连接WiFi',
|
||||||
|
showCancel: false,
|
||||||
|
buttonText: '我知道了'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先连接当前设备WiFi,否则无法访问后台管理',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmText: '我知道了',
|
||||||
|
success: (res) => res.confirm && resolve()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 跳转到设备后台管理地址
|
||||||
|
const url = deviceInfo.lan_ip.startsWith('http') ? deviceInfo.lan_ip : `http://${deviceInfo.lan_ip}`;
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = url;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef H5
|
||||||
|
uni.showModal({
|
||||||
|
title: '后台地址',
|
||||||
|
content: url,
|
||||||
|
confirmText: '复制',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '地址已复制',
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
const YesRestart = async () => {
|
const YesRestart = async () => {
|
||||||
try {
|
try {
|
||||||
await deviceApi.reboot(userStore.state.identifier);
|
await deviceApi.reboot(userStore.state.identifier);
|
||||||
uni.showToast({ title: '重启指令已发送', icon: 'success' });
|
uni.showToast({
|
||||||
|
title: '重启指令已发送',
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('重启设备失败', e);
|
console.error('重启设备失败', e);
|
||||||
}
|
}
|
||||||
@@ -288,7 +496,10 @@
|
|||||||
const YesRecover = async () => {
|
const YesRecover = async () => {
|
||||||
try {
|
try {
|
||||||
await deviceApi.factoryReset(userStore.state.identifier);
|
await deviceApi.factoryReset(userStore.state.identifier);
|
||||||
uni.showToast({ title: '恢复出厂设置指令已发送', icon: 'success' });
|
uni.showToast({
|
||||||
|
title: '恢复出厂设置指令已发送',
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('恢复出厂设置失败', e);
|
console.error('恢复出厂设置失败', e);
|
||||||
}
|
}
|
||||||
@@ -425,8 +636,65 @@
|
|||||||
showSmsCode.value = false;
|
showSmsCode.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSync = () => {
|
const onSync = async () => {
|
||||||
uni.showToast({ title: '同步成功', icon: 'success' });
|
const identifier = userStore.state.identifier;
|
||||||
|
if (!identifier) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '请先登录',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认同步',
|
||||||
|
content: '确定要同步运营商数据吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
uni.showLoading({
|
||||||
|
title: '同步中...',
|
||||||
|
mask: true
|
||||||
|
});
|
||||||
|
|
||||||
|
assetApi.refresh(identifier).then((data) => {
|
||||||
|
uni.hideLoading();
|
||||||
|
|
||||||
|
if (data.accepted) {
|
||||||
|
const refreshTypeText = data.refresh_type === 'device' ? '设备' : '卡';
|
||||||
|
uni.showToast({
|
||||||
|
title: `${refreshTypeText}数据同步成功`,
|
||||||
|
icon: 'success',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
loadAssetInfo();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
uni.showToast({
|
||||||
|
title: '同步失败,请稍后重试',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((e) => {
|
||||||
|
uni.hideLoading();
|
||||||
|
console.error('同步数据失败', e);
|
||||||
|
|
||||||
|
if (e.msg && e.msg.includes('冷却')) {
|
||||||
|
uni.showToast({
|
||||||
|
title: e.msg,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.showToast({
|
||||||
|
title: '同步失败',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterDetail = (name) => {
|
const enterDetail = (name) => {
|
||||||
@@ -460,8 +728,16 @@
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'change-phone':
|
case 'change-phone':
|
||||||
|
if (!boundPhone.value) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '请先去绑定手机号',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages/change-phone/change-phone'
|
url: `/pages/change-phone/change-phone?oldPhone=${boundPhone.value}`
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'device-exchange':
|
case 'device-exchange':
|
||||||
@@ -480,7 +756,7 @@
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'recover':
|
case 'recover':
|
||||||
restartShow.value = true;
|
recoverShow.value = true;
|
||||||
break;
|
break;
|
||||||
case 'restart':
|
case 'restart':
|
||||||
restartShow.value = true;
|
restartShow.value = true;
|
||||||
@@ -493,7 +769,10 @@
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initCurrentMonth();
|
initCurrentMonth();
|
||||||
loadAssetInfo();
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
handleIndexEntry();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -507,6 +786,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wifi-input {
|
||||||
|
border: 2rpx solid var(--border-light);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-spacer {
|
.bottom-spacer {
|
||||||
|
|||||||
@@ -1,14 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="login-page">
|
||||||
<view class="card">
|
<view class="content-wrapper">
|
||||||
<view class="title-login">
|
<view class="content">
|
||||||
登录
|
<view class="header">
|
||||||
|
<view class="main-title">欢迎登录</view>
|
||||||
|
<view class="sub-title">请输入标识进行登录</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-section">
|
||||||
|
<view class="input-wrap" :class="{ focus: inputFocus }">
|
||||||
|
<input
|
||||||
|
v-model="identifier"
|
||||||
|
class="input"
|
||||||
|
placeholder="请输入标识"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@focus="inputFocus = true"
|
||||||
|
@blur="handleBlur"
|
||||||
|
@input="showError = false"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="identifier && inputFocus"
|
||||||
|
class="clear-icon"
|
||||||
|
src="/static/clear.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
@mousedown.prevent="clearInput"
|
||||||
|
@touchstart.prevent="clearInput"
|
||||||
|
></image>
|
||||||
|
</view>
|
||||||
|
<text v-if="showError" class="error">请输入标识</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="btn-section">
|
||||||
|
<button
|
||||||
|
class="btn-login"
|
||||||
|
:class="{ disabled: !identifier || loading || !agreed }"
|
||||||
|
:disabled="!identifier || loading || !agreed"
|
||||||
|
@tap="handleLogin"
|
||||||
|
>
|
||||||
|
<view v-if="loading" class="loading">
|
||||||
|
<view class="dot"/>
|
||||||
|
<view class="dot"/>
|
||||||
|
<view class="dot"/>
|
||||||
|
</view>
|
||||||
|
<text v-else>立即登录</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-scan" @tap="handleScanLogin">
|
||||||
|
<image src="/static/scan-code.png" mode="aspectFit" class="scan-icon"></image>
|
||||||
|
<text>扫一扫登录</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="agreement">
|
||||||
|
<view class="agreement-text">
|
||||||
|
<text class="text">登录即表示同意</text>
|
||||||
|
<text class="link">《用户协议》</text>
|
||||||
|
<text class="text">和</text>
|
||||||
|
<text class="link">《隐私政策》</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="input">
|
|
||||||
<up-input class="mt-30" v-model="identifier" placeholder="请输入SN/IMEI/虚拟号/ICCID/MSISDN"></up-input>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="button">
|
|
||||||
<up-button class="mt-30 btn-apple btn-primary" type="primary" :loading="loading" @click="login">立即登录</up-button>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -16,16 +66,73 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { authApi } from '@/api/index.js';
|
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||||
|
import { authApi, wechatApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
import { APP_ID } from '@/utils/env.js';
|
import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js';
|
||||||
|
|
||||||
|
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const identifier = ref(uni.getStorageSync('identifier') || '');
|
||||||
const identifier = ref('');
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const agreed = ref(true);
|
||||||
|
const inputFocus = ref(false);
|
||||||
|
const showError = ref(false);
|
||||||
|
const wechatAppId = ref('');
|
||||||
|
|
||||||
const isWechat = /micromessenger/i.test(navigator.userAgent);
|
const resetLoadingState = () => {
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const token = uni.getStorageSync('token');
|
||||||
|
if (token) {
|
||||||
|
uni.reLaunch({ url: '/pages/index/index' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPostBindReloginNotice();
|
||||||
|
handleWechatCallback();
|
||||||
|
getPathDeviceId();
|
||||||
|
|
||||||
|
// 初始化微信 SDK (仅在微信浏览器内执行)
|
||||||
|
// #ifdef H5
|
||||||
|
if (isInWechat()) {
|
||||||
|
try {
|
||||||
|
await initWxConfig();
|
||||||
|
console.log('微信 SDK 初始化成功');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('微信 SDK 初始化失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
resetLoadingState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
resetLoadingState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
resetLoadingState();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPostBindReloginNotice = () => {
|
||||||
|
if (uni.getStorageSync(POST_BIND_RELOGIN_NOTICE_KEY) !== '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.removeStorageSync(POST_BIND_RELOGIN_NOTICE_KEY);
|
||||||
|
uni.showToast({
|
||||||
|
title: '绑定手机号成功,请重新登录',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2500
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getCode = () => {
|
const getCode = () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -36,31 +143,114 @@
|
|||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectToWxAuth = (assetToken) => {
|
const getWechatAppId = async () => {
|
||||||
const redirectUri = encodeURIComponent(window.location.href.split('?')[0]);
|
if (wechatAppId.value) {
|
||||||
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${APP_ID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=${assetToken}#wechat_redirect`;
|
return wechatAppId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await wechatApi.getAppId();
|
||||||
|
if (!data || !data.app_id) {
|
||||||
|
uni.showToast({ title: '微信公众号配置缺失', icon: 'none' });
|
||||||
|
throw new Error('wechat app_id is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
wechatAppId.value = data.app_id;
|
||||||
|
return wechatAppId.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectToWxAuth = async (assetToken) => {
|
||||||
|
sessionStorage.setItem('assetToken', assetToken);
|
||||||
|
const appId = await getWechatAppId();
|
||||||
|
const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname);
|
||||||
|
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`;
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = async () => {
|
const clearInput = () => {
|
||||||
|
identifier.value = '';
|
||||||
|
showError.value = false;
|
||||||
|
inputFocus.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputFocus.value = false;
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
if (!identifier.value) {
|
if (!identifier.value) {
|
||||||
uni.showToast({ title: '请输入SN/IMEI/虚拟号/ICCID/MSISDN', icon: 'none' });
|
showError.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doLogin();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseScanIdentifier = (resultStr) => {
|
||||||
|
const scanValue = (resultStr || '').trim();
|
||||||
|
if (!scanValue) return '';
|
||||||
|
|
||||||
|
const barcodeMatch = scanValue.match(/^[A-Z0-9_]+,(.+)$/);
|
||||||
|
if (barcodeMatch) return barcodeMatch[1].trim();
|
||||||
|
|
||||||
|
if (scanValue.includes('?')) {
|
||||||
|
const params = new URLSearchParams(scanValue.split('?')[1]);
|
||||||
|
const queryValue = params.get('cardNum') || params.get('identifier');
|
||||||
|
if (queryValue) return queryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = scanValue.match(/=([^=]+)$/);
|
||||||
|
return match ? match[1].trim() : scanValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanLogin = async () => {
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
// 检查是否在微信环境
|
||||||
|
if (!isInWechat()) {
|
||||||
|
uni.showToast({ title: '请在微信中打开', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用扫一扫
|
||||||
|
const resultStr = await wxScan();
|
||||||
|
console.log('扫码结果:', resultStr);
|
||||||
|
|
||||||
|
const value = parseScanIdentifier(resultStr);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
identifier.value = value;
|
||||||
|
handleLogin();
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '无效的扫码内容', icon: 'none' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('扫码错误:', err);
|
||||||
|
// uni.showToast({ title: JSON.stringify(err), icon: 'none' });
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
uni.showToast({ title: '请在微信中打开', icon: 'none' });
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPathDeviceId = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const idf = params.get('identifier');
|
||||||
|
if (idf) {
|
||||||
|
identifier.value = idf;
|
||||||
|
handleLogin();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const verifyData = await authApi.verifyAsset(identifier.value);
|
const verifyData = await authApi.verifyAsset(identifier.value);
|
||||||
userStore.setAssetToken(verifyData.asset_token);
|
userStore.setAssetToken(verifyData.asset_token);
|
||||||
userStore.setIdentifier(identifier.value);
|
userStore.setIdentifier(identifier.value);
|
||||||
|
|
||||||
// if (!isWechat) {
|
await redirectToWxAuth(verifyData.asset_token);
|
||||||
// uni.showToast({ title: '请在微信中打开', icon: 'none' });
|
|
||||||
// loading.value = false;
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
redirectToWxAuth(verifyData.asset_token);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('登录失败', e);
|
console.error('登录失败', e);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -69,7 +259,7 @@
|
|||||||
|
|
||||||
const handleWechatCallback = async () => {
|
const handleWechatCallback = async () => {
|
||||||
const code = getCode();
|
const code = getCode();
|
||||||
const assetToken = userStore.state.assetToken;
|
const assetToken = sessionStorage.getItem('assetToken');
|
||||||
|
|
||||||
if (!code || !assetToken) return;
|
if (!code || !assetToken) return;
|
||||||
|
|
||||||
@@ -79,38 +269,236 @@
|
|||||||
try {
|
try {
|
||||||
const loginData = await authApi.wechatLogin(assetToken, code);
|
const loginData = await authApi.wechatLogin(assetToken, code);
|
||||||
userStore.setToken(loginData.token);
|
userStore.setToken(loginData.token);
|
||||||
|
|
||||||
uni.setStorageSync('identifier', userStore.state.identifier);
|
uni.setStorageSync('identifier', userStore.state.identifier);
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
if (loginData.user_info) {
|
||||||
|
userStore.setUserInfo({
|
||||||
|
avatar: loginData.user_info.avatar || '',
|
||||||
|
nickname: loginData.user_info.nickname || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
if (loginData.need_bind_phone) {
|
||||||
|
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
|
||||||
|
} else {
|
||||||
uni.redirectTo({ url: '/pages/index/index' });
|
uni.redirectTo({ url: '/pages/index/index' });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('微信登录失败', e);
|
console.error('微信登录失败', e);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
handleWechatCallback();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.container {
|
.login-page {
|
||||||
padding-top: 30vh;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
max-width: 750rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.title-login {
|
.content-wrapper {
|
||||||
color: var(--text-primary);
|
width: 100%;
|
||||||
font-size: 24px;
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
padding: 60rpx 40rpx;
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 20px 0;
|
margin-bottom: 60rpx;
|
||||||
font-weight: bold;
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
letter-spacing: -0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2rpx solid var(--border-color);
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.focus {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 4rpx rgba(10, 132, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
margin: 70rpx 0 50rpx 0;
|
flex: 1;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.placeholder {
|
||||||
margin-bottom: 40rpx;
|
color: var(--text-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-icon {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-icon:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--danger);
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding-left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-section {
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
height: 96rpx;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-inverse);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(10, 132, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login.disabled {
|
||||||
|
background: var(--gray-300);
|
||||||
|
color: var(--text-quaternary);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active:not(.disabled) {
|
||||||
|
transform: translateY(2rpx);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(10, 132, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-scan {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 2rpx solid var(--primary);
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-scan:active {
|
||||||
|
background: rgba(10, 132, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-icon {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
background: var(--text-inverse);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: load 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(1) { animation-delay: -0.32s; }
|
||||||
|
.dot:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
|
||||||
|
@keyframes load {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +1,115 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
|
<view class="filter-tabs">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in filterOptions"
|
||||||
|
:key="index"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: filterIndex === index }"
|
||||||
|
@tap="onFilterChange(index)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
<view v-if="filterIndex === index" class="tab-line"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y class="scroll-container" @scrolltolower="loadMore">
|
||||||
<view v-if="orderList.length === 0 && !loading" class="empty-state">
|
<view v-if="orderList.length === 0 && !loading" class="empty-state">
|
||||||
<view class="empty-icon">📋</view>
|
<view class="empty-icon">ORD</view>
|
||||||
<view class="empty-title">暂无订单</view>
|
<view class="empty-title">暂无订单</view>
|
||||||
<view class="empty-desc">当前账号下暂无订单信息</view>
|
<view class="empty-desc">当前账号下暂无订单信息</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="card order-card" v-for="(item, index) in orderList" :key="index">
|
<view v-else class="order-list">
|
||||||
<view class="order-header flex-row-sb">
|
<view class="order-card" v-for="item in orderList" :key="item.order_id">
|
||||||
<view class="order-no">{{ item.order_no }}</view>
|
<view class="card-header">
|
||||||
<view class="tag-apple" :class="getStatusClass(item.payment_status)">{{ item.payment_status_name }}</view>
|
<view class="header-left">
|
||||||
|
<view class="header-row">
|
||||||
|
<text class="field-tag">订单号</text>
|
||||||
|
<text class="order-no">{{ item.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="status-badge" :class="getStatusClass(item.payment_status)">
|
||||||
|
{{ item.payment_status_name }}
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-info">
|
<view class="card-body">
|
||||||
<view class="info-row flex-row-sb">
|
<view class="info-grid">
|
||||||
|
<view class="info-item">
|
||||||
|
<view class="info-label">套餐名称</view>
|
||||||
|
<view class="info-value">
|
||||||
|
<view class="package-item" v-for="(pkgName, pkgIndex) in item.package_names" :key="pkgIndex">
|
||||||
|
<text class="package-name">{{ pkgName }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<view class="info-label">订单金额</view>
|
||||||
|
<view class="info-value amount">¥{{ formatMoney(item.total_amount) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
<view class="info-label">下单时间</view>
|
<view class="info-label">下单时间</view>
|
||||||
<view class="info-value">{{ item.created_at }}</view>
|
<view class="info-value">{{ item.created_at }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="divider"></view>
|
<view v-if="item.payment_status === 1" class="card-footer">
|
||||||
|
<button class="btn-pay" :disabled="orderPayingId !== null" @tap="showOrderPaymentMethods(item)">
|
||||||
<view class="package-list">
|
{{ isOrderPaying(item) ? '处理中...' : '立即支付' }}
|
||||||
<view class="package-item" v-for="(pkgName, pIndex) in item.package_names" :key="pIndex">
|
</button>
|
||||||
<view class="package-name">{{ pkgName }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-footer flex-row-sb">
|
|
||||||
<view class="total-label">合计</view>
|
|
||||||
<view class="total-amount">¥{{ formatMoney(item.total_amount) }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="load-more" v-if="orderList.length > 0">
|
<view class="load-more" v-if="orderList.length > 0">
|
||||||
<text v-if="loading">加载中...</text>
|
<text v-if="loading">加载中...</text>
|
||||||
<text v-else-if="noMore">没有更多了</text>
|
<text v-else-if="noMore">没有更多了</text>
|
||||||
<text v-else @tap="loadMore">点击加载更多</text>
|
<text v-else>上拉加载更多</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
import { orderApi } from '@/api/index.js';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
import { assetApi, orderApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import {
|
||||||
|
consumePendingPaymentRefresh,
|
||||||
|
handlePaymentError,
|
||||||
|
isValidAlipayPaymentLink,
|
||||||
|
isValidWechatPayConfig,
|
||||||
|
openAlipayPayment,
|
||||||
|
PAYMENT_REFRESH_TARGETS,
|
||||||
|
showPaymentToast,
|
||||||
|
wechatH5Pay
|
||||||
|
} from '@/utils/payment.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
let orderList = reactive([]);
|
const orderList = reactive([]);
|
||||||
let loading = ref(false);
|
const loading = ref(false);
|
||||||
let noMore = ref(false);
|
const noMore = ref(false);
|
||||||
let page = ref(1);
|
const page = ref(1);
|
||||||
|
const orderPayingId = ref(null);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
const filterIndex = ref(0);
|
||||||
|
const isDeviceAsset = ref(true);
|
||||||
|
let assetTypePromise = null;
|
||||||
|
const paymentMethodOptions = computed(() => [
|
||||||
|
{ label: '支付宝支付', value: 'alipay' },
|
||||||
|
{ label: '钱包支付', value: 'wallet' }
|
||||||
|
]);
|
||||||
|
const filterOptions = [
|
||||||
|
{ label: '全部', value: null },
|
||||||
|
{ label: '待支付', value: 1 },
|
||||||
|
{ label: '已支付', value: 2 },
|
||||||
|
{ label: '已取消', value: 3 },
|
||||||
|
{ label: '已退款', value: 4 }
|
||||||
|
];
|
||||||
|
|
||||||
const formatMoney = (amount) => {
|
const formatMoney = (amount) => {
|
||||||
if (!amount && amount !== 0) return '0.00';
|
if (!amount && amount !== 0) return '0.00';
|
||||||
@@ -61,27 +118,82 @@
|
|||||||
|
|
||||||
const getStatusClass = (status) => {
|
const getStatusClass = (status) => {
|
||||||
const classMap = {
|
const classMap = {
|
||||||
'0': 'tag-warning',
|
1: 'tag-warning',
|
||||||
'1': 'tag-success',
|
2: 'tag-success',
|
||||||
'2': 'tag-danger'
|
3: 'tag-secondary',
|
||||||
|
4: 'tag-info'
|
||||||
};
|
};
|
||||||
return classMap[status] || '';
|
return classMap[status] || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAssetType = async () => {
|
||||||
|
if (!userStore.state.identifier) return;
|
||||||
|
if (assetTypePromise) return assetTypePromise;
|
||||||
|
|
||||||
|
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
|
||||||
|
.then((data) => {
|
||||||
|
isDeviceAsset.value = data.asset_type === 'device';
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('加载资产类型失败', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
assetTypePromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return assetTypePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetOrderListAndLoad = () => {
|
||||||
|
page.value = 1;
|
||||||
|
noMore.value = false;
|
||||||
|
loadOrderList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleOrderStatusRefresh = () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '正在同步支付状态',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
resetOrderListAndLoad();
|
||||||
|
setTimeout(() => {
|
||||||
|
resetOrderListAndLoad();
|
||||||
|
}, 1500);
|
||||||
|
setTimeout(() => {
|
||||||
|
resetOrderListAndLoad();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterChange = (index) => {
|
||||||
|
filterIndex.value = index;
|
||||||
|
resetOrderListAndLoad();
|
||||||
|
};
|
||||||
|
|
||||||
const loadOrderList = async (append = false) => {
|
const loadOrderList = async (append = false) => {
|
||||||
if (loading.value || noMore.value) return;
|
if (loading.value || (append && noMore.value)) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
const paymentStatus = filterOptions[filterIndex.value].value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await orderApi.getList(
|
const data = await orderApi.getList(
|
||||||
userStore.state.identifier,
|
userStore.state.identifier,
|
||||||
page.value,
|
page.value,
|
||||||
pageSize
|
pageSize,
|
||||||
|
paymentStatus
|
||||||
);
|
);
|
||||||
|
|
||||||
const newData = (data.items || []).map(item => ({
|
const newData = (data.items || []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
payment_status_name: item.payment_status === 1 ? '已支付' : item.payment_status === 0 ? '待支付' : '已取消',
|
payment_status_name: item.payment_status === 1
|
||||||
|
? '待支付'
|
||||||
|
: item.payment_status === 2
|
||||||
|
? '已支付'
|
||||||
|
: item.payment_status === 3
|
||||||
|
? '已取消'
|
||||||
|
: item.payment_status === 4
|
||||||
|
? '已退款'
|
||||||
|
: '未知',
|
||||||
created_at: item.created_at ? item.created_at.split('T').join(' ').slice(0, 19) : ''
|
created_at: item.created_at ? item.created_at.split('T').join(' ').slice(0, 19) : ''
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -94,11 +206,12 @@
|
|||||||
if (newData.length < pageSize) {
|
if (newData.length < pageSize) {
|
||||||
noMore.value = true;
|
noMore.value = true;
|
||||||
} else {
|
} else {
|
||||||
page.value++;
|
page.value += 1;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error('加载订单列表失败', e);
|
console.error('加载订单列表失败', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,69 +221,329 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOrderPaying = (order) => orderPayingId.value === order.order_id;
|
||||||
|
|
||||||
|
const showOrderPaymentMethods = async (order) => {
|
||||||
|
if (orderPayingId.value !== null || !order?.order_id) return;
|
||||||
|
await loadAssetType();
|
||||||
|
const options = paymentMethodOptions.value;
|
||||||
|
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: options.map((item) => item.label),
|
||||||
|
success: ({ tapIndex }) => {
|
||||||
|
const selectedMethod = options[tapIndex]?.value;
|
||||||
|
if (selectedMethod) {
|
||||||
|
handleOrderPayment(order, selectedMethod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrderPayment = async (order, paymentMethod) => {
|
||||||
|
if (orderPayingId.value !== null || !order?.order_id) return;
|
||||||
|
|
||||||
|
orderPayingId.value = order.order_id;
|
||||||
|
uni.showLoading({
|
||||||
|
title: '处理中...',
|
||||||
|
mask: true
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payData = await orderApi.pay(order.order_id, paymentMethod);
|
||||||
|
uni.hideLoading();
|
||||||
|
|
||||||
|
if (paymentMethod === 'wallet') {
|
||||||
|
showPaymentToast(true, '支付成功');
|
||||||
|
setTimeout(() => {
|
||||||
|
resetOrderListAndLoad();
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentMethod === 'wechat') {
|
||||||
|
if (!isValidWechatPayConfig(payData?.pay_config)) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '支付参数获取失败',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wechatH5Pay(payData.pay_config);
|
||||||
|
showPaymentToast(true, '支付成功');
|
||||||
|
setTimeout(() => {
|
||||||
|
resetOrderListAndLoad();
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
handlePaymentError(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidAlipayPaymentLink(payData?.payment_link)) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '支付链接获取失败',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openAlipayPayment(payData.payment_link, PAYMENT_REFRESH_TARGETS.ORDER_LIST);
|
||||||
|
} catch (error) {
|
||||||
|
uni.hideLoading();
|
||||||
|
console.error('订单支付失败', error);
|
||||||
|
uni.showToast({
|
||||||
|
title: error.msg || error.message || '支付失败,请稍后重试',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
orderPayingId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.ORDER_LIST)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleOrderStatusRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadAssetType();
|
||||||
loadOrderList();
|
loadOrderList();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.container {
|
.container {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--window-top);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
max-width: 750rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
z-index: 100;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
padding: 28rpx 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-line {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 3rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 200rpx 40rpx;
|
||||||
min-height: 400rpx;
|
|
||||||
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
.empty-icon {
|
||||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
font-size: 56rpx;
|
||||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
margin-bottom: 30rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-card {
|
.order-card {
|
||||||
margin-bottom: var(--space-md);
|
background: #fff;
|
||||||
.order-header {
|
border-radius: 20rpx;
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: 24rpx;
|
||||||
.order-no { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 28rpx 28rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-info {
|
.header-row {
|
||||||
.info-row {
|
display: flex;
|
||||||
padding: var(--space-xs) 0;
|
align-items: center;
|
||||||
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
|
gap: 12rpx;
|
||||||
.info-value { font-size: 24rpx; color: var(--text-primary); }
|
}
|
||||||
|
|
||||||
|
.field-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.card-body {
|
||||||
height: 1rpx;
|
padding: 24rpx 28rpx;
|
||||||
background: var(--gray-200);
|
|
||||||
margin: var(--space-md) 0;
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
.info-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&.amount {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-list {
|
|
||||||
.package-item {
|
.package-item {
|
||||||
padding: var(--space-sm) 0;
|
display: flex;
|
||||||
border-bottom: 1rpx solid var(--gray-100);
|
align-items: center;
|
||||||
&:last-child { border-bottom: none; }
|
padding: 8rpx 0;
|
||||||
.package-name { font-size: 26rpx; color: var(--text-primary); }
|
}
|
||||||
|
|
||||||
|
.package-name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.btn-pay {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 16rpx 40rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-footer {
|
.status-badge {
|
||||||
margin-top: var(--space-md);
|
padding: 8rpx 20rpx;
|
||||||
padding-top: var(--space-md);
|
border-radius: 30rpx;
|
||||||
border-top: 1rpx solid var(--gray-200);
|
font-size: 22rpx;
|
||||||
.total-label { font-size: 26rpx; color: var(--text-secondary); }
|
font-weight: 500;
|
||||||
.total-amount { font-size: 32rpx; font-weight: 700; color: var(--danger); }
|
|
||||||
|
&.tag-warning {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tag-success {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tag-secondary {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tag-info {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more {
|
.load-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-lg);
|
padding: 32rpx;
|
||||||
color: var(--text-tertiary);
|
color: #999;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,49 +1,119 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view v-if="packageList.length === 0 && !loading" class="empty-state">
|
<view v-if="packageList.length === 0 && !loading" class="empty-state">
|
||||||
<view class="empty-icon">📦</view>
|
<view class="empty-icon">PKG</view>
|
||||||
<view class="empty-title">暂无可购套餐</view>
|
<view class="empty-title">暂无可购套餐,请联系客服</view>
|
||||||
<view class="empty-desc">当前资产暂无适用的套餐</view>
|
<view class="empty-desc">当前资产暂无适用的套餐</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card package-card" v-for="item in packageList" :key="item.package_id">
|
<view class="card package-card" v-for="item in packageList" :key="item.package_id">
|
||||||
<view class="package-header flex-row-sb">
|
<view class="package-header">
|
||||||
<view class="package-name">{{ item.package_name }}</view>
|
<view class="package-name">{{ item.package_name }}</view>
|
||||||
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
|
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
|
||||||
{{ item.is_addon ? '加油包' : '正式套餐' }}
|
{{ item.is_addon ? '加油包' : '正式套餐' }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="package-price">¥{{ formatMoney(item.retail_price) }}</view>
|
<view class="package-main">
|
||||||
<view class="package-desc">{{ item.description }}</view>
|
<view class="data-block">
|
||||||
<view class="package-info">
|
<view class="data-label">套餐流量</view>
|
||||||
<view class="info-item">流量: {{ formatData(item.data_allowance, item.data_unit) }}</view>
|
<view class="package-data">{{ formatData(item.data_allowance, item.data_unit) }}</view>
|
||||||
<view class="info-item">有效: {{ item.validity_days }}天</view>
|
</view>
|
||||||
|
<view class="validity-box">
|
||||||
|
<view class="validity-value">{{ item.validity_days }}</view>
|
||||||
|
<view class="validity-label">天有效期</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="package-desc" v-if="item.description">{{ item.description }}</view>
|
||||||
|
<view class="package-footer">
|
||||||
|
<view class="price-block">
|
||||||
|
<text class="price-symbol">¥</text>
|
||||||
|
<text class="package-price">{{ formatMoney(item.retail_price) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="btn">
|
<view class="btn">
|
||||||
<up-button type="primary" @click="buyPackage(item)">立即订购</up-button>
|
<up-button type="primary" @click="buyPackage(item)">立即订购</up-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<up-modal title="确认购买" :show="showModal" showCancelButton @confirm="confirmBuy" @cancel="showModal=false">
|
|
||||||
<view style="padding: 40rpx; text-align: center;">
|
|
||||||
<view>套餐名称:{{ currentPackage?.package_name }}</view>
|
|
||||||
<view class="mt-20">价格:¥{{ formatMoney(currentPackage?.retail_price) }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</up-modal>
|
|
||||||
|
<up-popup :show="showModal" mode="center" @close="showModal = false">
|
||||||
|
<view class="payment-popup">
|
||||||
|
<view class="popup-header">
|
||||||
|
<view class="popup-title">选择支付方式</view>
|
||||||
|
<view class="popup-close" @tap="showModal = false">×</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="package-summary">
|
||||||
|
<view class="summary-name">{{ currentPackage?.package_name }}</view>
|
||||||
|
<view class="summary-price">¥{{ formatMoney(currentPackage?.retail_price) }}</view>
|
||||||
|
<view class="summary-details">
|
||||||
|
<view class="detail-item">
|
||||||
|
<text class="detail-label">套餐流量</text>
|
||||||
|
<text class="detail-value">{{ formatData(currentPackage?.data_allowance, currentPackage?.data_unit) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-divider"></view>
|
||||||
|
<view class="detail-item">
|
||||||
|
<text class="detail-label">有效期</text>
|
||||||
|
<text class="detail-value">{{ currentPackage?.validity_days }} 天</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="payment-methods">
|
||||||
|
<view class="method-item" :class="{ active: paymentMethod === 'alipay' }" @tap="selectPaymentMethod('alipay')">
|
||||||
|
<view class="method-left">
|
||||||
|
<view class="method-icon method-badge method-badge-alipay">支</view>
|
||||||
|
<text class="method-name">支付宝支付</text>
|
||||||
|
</view>
|
||||||
|
<view class="method-radio" :class="{ checked: paymentMethod === 'alipay' }"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="method-item" :class="{ active: paymentMethod === 'wallet' }" @tap="selectPaymentMethod('wallet')">
|
||||||
|
<view class="method-left">
|
||||||
|
<image class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
|
||||||
|
<text class="method-name">账户余额(¥{{ formatMoney(walletBalance) }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="method-radio" :class="{ checked: paymentMethod === 'wallet' }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="popup-footer">
|
||||||
|
<button class="btn-apple btn-secondary" @tap="showModal = false">取消</button>
|
||||||
|
<button class="btn-apple btn-primary" :disabled="paySubmitting" @tap="confirmPay">
|
||||||
|
{{ paySubmitting ? '处理中...' : '确认支付' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</up-popup>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
import { assetApi, orderApi } from '@/api/index.js';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
import { assetApi, orderApi, walletApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import {
|
||||||
|
consumePendingPaymentRefresh,
|
||||||
|
handlePaymentError,
|
||||||
|
isValidAlipayPaymentLink,
|
||||||
|
isValidWechatPayConfig,
|
||||||
|
openAlipayPayment,
|
||||||
|
PAYMENT_REFRESH_TARGETS,
|
||||||
|
showPaymentToast,
|
||||||
|
wechatH5Pay
|
||||||
|
} from '@/utils/payment.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
let showModal = ref(false);
|
const showModal = ref(false);
|
||||||
let currentPackage = ref(null);
|
const currentPackage = ref(null);
|
||||||
let packageList = reactive([]);
|
const packageList = reactive([]);
|
||||||
let loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const paymentMethod = ref('alipay');
|
||||||
|
const walletBalance = ref(0);
|
||||||
|
const paySubmitting = ref(false);
|
||||||
|
const isDeviceAsset = ref(true);
|
||||||
|
let assetTypePromise = null;
|
||||||
|
|
||||||
const formatMoney = (amount) => {
|
const formatMoney = (amount) => {
|
||||||
if (!amount && amount !== 0) return '0.00';
|
if (!amount && amount !== 0) return '0.00';
|
||||||
@@ -52,9 +122,43 @@
|
|||||||
|
|
||||||
const formatData = (allowance, unit) => {
|
const formatData = (allowance, unit) => {
|
||||||
if (unit === 'MB') {
|
if (unit === 'MB') {
|
||||||
return allowance >= 1024 ? (allowance / 1024).toFixed(0) + ' GB' : allowance + ' MB';
|
return allowance >= 1024 ? `${(allowance / 1024).toFixed(0)} GB` : `${allowance} MB`;
|
||||||
}
|
}
|
||||||
return allowance + ' ' + unit;
|
return `${allowance} ${unit}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultPaymentMethod = () => 'alipay';
|
||||||
|
|
||||||
|
const isPaymentMethodAvailable = (method) => {
|
||||||
|
if (method === 'wallet') return true;
|
||||||
|
if (method === 'wechat') return false;
|
||||||
|
if (method === 'alipay') return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePaymentMethod = () => {
|
||||||
|
if (!isPaymentMethodAvailable(paymentMethod.value)) {
|
||||||
|
paymentMethod.value = getDefaultPaymentMethod();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAssetType = async () => {
|
||||||
|
if (!userStore.state.identifier) return;
|
||||||
|
if (assetTypePromise) return assetTypePromise;
|
||||||
|
|
||||||
|
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
|
||||||
|
.then((data) => {
|
||||||
|
isDeviceAsset.value = data.asset_type === 'device';
|
||||||
|
normalizePaymentMethod();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('加载资产类型失败', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
assetTypePromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return assetTypePromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPackages = async () => {
|
const loadPackages = async () => {
|
||||||
@@ -62,29 +166,250 @@
|
|||||||
try {
|
try {
|
||||||
const data = await assetApi.getPackages(userStore.state.identifier);
|
const data = await assetApi.getPackages(userStore.state.identifier);
|
||||||
packageList.splice(0, packageList.length, ...(data.packages || []));
|
packageList.splice(0, packageList.length, ...(data.packages || []));
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error('加载套餐列表失败', e);
|
console.error('加载套餐列表失败', error);
|
||||||
}
|
}
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buyPackage = (item) => {
|
const loadWalletBalance = async () => {
|
||||||
|
try {
|
||||||
|
const data = await walletApi.getDetail(userStore.state.identifier);
|
||||||
|
walletBalance.value = data.balance || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载钱包余额失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPackagePageState = () => {
|
||||||
|
loadAssetType();
|
||||||
|
loadPackages();
|
||||||
|
loadWalletBalance();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buyPackage = async (item) => {
|
||||||
|
await loadAssetType();
|
||||||
currentPackage.value = item;
|
currentPackage.value = item;
|
||||||
|
paymentMethod.value = getDefaultPaymentMethod();
|
||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmBuy = async () => {
|
const selectPaymentMethod = (method) => {
|
||||||
try {
|
if (!isPaymentMethodAvailable(method)) return;
|
||||||
await orderApi.create(userStore.state.identifier, [currentPackage.value.package_id]);
|
paymentMethod.value = method;
|
||||||
uni.showToast({ title: '购买成功', icon: 'success' });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('购买套餐失败', e);
|
|
||||||
}
|
|
||||||
showModal.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasPreparedPaymentData = (paymentData) => {
|
||||||
|
return isValidWechatPayConfig(paymentData?.pay_config) ||
|
||||||
|
isValidAlipayPaymentLink(paymentData?.payment_link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWechatPay = async (payConfig, isForceRecharge = false) => {
|
||||||
|
try {
|
||||||
|
await wechatH5Pay(payConfig);
|
||||||
|
showPaymentToast(true, isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadWalletBalance();
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
handlePaymentError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreparedPayment = async (paymentData, isForceRecharge = false) => {
|
||||||
|
if (isValidWechatPayConfig(paymentData?.pay_config)) {
|
||||||
|
await handleWechatPay(paymentData.pay_config, isForceRecharge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidAlipayPaymentLink(paymentData?.payment_link)) {
|
||||||
|
await openAlipayPayment(paymentData.payment_link, PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({
|
||||||
|
title: '支付参数获取失败',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreateOrderPaymentMethod = () => {
|
||||||
|
if (paymentMethod.value === 'alipay') return 'alipay';
|
||||||
|
if (paymentMethod.value === 'wallet') return 'alipay';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPay = async () => {
|
||||||
|
if (paySubmitting.value || !currentPackage.value) return;
|
||||||
|
|
||||||
|
paySubmitting.value = true;
|
||||||
|
let releaseInModalCallback = false;
|
||||||
|
|
||||||
|
uni.showLoading({
|
||||||
|
title: '创建订单...',
|
||||||
|
mask: true
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orderResult = await orderApi.create(
|
||||||
|
userStore.state.identifier,
|
||||||
|
[currentPackage.value.package_id],
|
||||||
|
getCreateOrderPaymentMethod()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderResult.order_type === 'recharge' && orderResult.recharge) {
|
||||||
|
const recharge = orderResult.recharge;
|
||||||
|
|
||||||
|
if (recharge.status === 2 || recharge.status === 3) {
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
uni.showToast({
|
||||||
|
title: `充值订单${recharge.status_name},请重新下单`,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recharge.status === 1 && !hasPreparedPaymentData(orderResult)) {
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '检测到您有待支付的充值订单,是否前往我的钱包查看?',
|
||||||
|
confirmText: '去查看',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/my-wallet/my-wallet' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPreparedPaymentData(orderResult)) {
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
uni.showToast({
|
||||||
|
title: '支付参数获取失败',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceRechargeAmount = orderResult.linked_package_info?.force_recharge_amount || recharge.amount;
|
||||||
|
const packageNames = orderResult.linked_package_info?.package_names || [];
|
||||||
|
const packageNameStr = packageNames.length > 0 ? packageNames.join('、') : '套餐';
|
||||||
|
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
releaseInModalCallback = true;
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '需要充值',
|
||||||
|
content: `购买${packageNameStr}需要先充值 ¥${formatMoney(forceRechargeAmount)},充值成功后将自动购买套餐`,
|
||||||
|
confirmText: '去充值',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: async (res) => {
|
||||||
|
try {
|
||||||
|
if (res.confirm) {
|
||||||
|
await handlePreparedPayment(orderResult, true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
paySubmitting.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
paySubmitting.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderResult?.order?.order_id) {
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
uni.showToast({
|
||||||
|
title: '订单创建失败',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showLoading({
|
||||||
|
title: '准备支付...',
|
||||||
|
mask: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const payResult = await orderApi.pay(orderResult.order.order_id, paymentMethod.value);
|
||||||
|
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
|
||||||
|
if (paymentMethod.value === 'wallet') {
|
||||||
|
uni.showToast({
|
||||||
|
title: '支付成功',
|
||||||
|
icon: 'success'
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
loadWalletBalance();
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handlePreparedPayment(payResult);
|
||||||
|
} catch (error) {
|
||||||
|
uni.hideLoading();
|
||||||
|
showModal.value = false;
|
||||||
|
console.error('创建订单或支付失败', error);
|
||||||
|
|
||||||
|
if (error.code === 1008) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '订单正在创建中,是否跳转到我的订单页面进行支付?',
|
||||||
|
confirmText: '去支付',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/order-list/order-list' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({
|
||||||
|
title: error.msg || error.message || '操作失败,请稍后重试',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (!releaseInModalCallback) {
|
||||||
|
paySubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPackagePageState();
|
||||||
|
uni.showModal({
|
||||||
|
title: '支付状态确认',
|
||||||
|
content: '已返回页面,支付结果以订单状态为准。是否前往我的订单查看最新状态?',
|
||||||
|
confirmText: '查看订单',
|
||||||
|
cancelText: '稍后',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/order-list/order-list' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPackages();
|
syncPackagePageState();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -97,35 +422,346 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 120rpx 40rpx;
|
||||||
min-height: 400rpx;
|
min-height: 400rpx;
|
||||||
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
|
||||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
.empty-icon {
|
||||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
font-size: 60rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-card {
|
.package-card {
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
padding: 32rpx;
|
||||||
|
border: 1rpx solid rgba(0, 122, 255, 0.08);
|
||||||
|
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.06);
|
||||||
|
|
||||||
.package-header {
|
.package-header {
|
||||||
margin-bottom: var(--space-sm);
|
display: flex;
|
||||||
.package-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
|
||||||
|
.package-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.package-price {
|
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--warning);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.package-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 26rpx;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 122, 255, 0.08) 0%, rgba(0, 122, 255, 0.02) 100%);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
|
||||||
|
.data-block {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.data-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-data {
|
||||||
|
font-size: 52rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validity-box {
|
||||||
|
min-width: 132rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18rpx 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border: 1rpx solid rgba(0, 122, 255, 0.08);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
|
||||||
|
.validity-value {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validity-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.package-desc {
|
.package-desc {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: var(--space-sm);
|
line-height: 1.55;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
.package-info {
|
|
||||||
|
.package-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-lg);
|
align-items: center;
|
||||||
margin-bottom: var(--space-md);
|
justify-content: space-between;
|
||||||
.info-item { font-size: 24rpx; color: var(--text-tertiary); }
|
gap: 24rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1rpx solid var(--border-light);
|
||||||
|
|
||||||
|
.price-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--warning);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.price-symbol {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-price {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 180rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-popup {
|
||||||
|
width: 600rpx;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 30rpx;
|
||||||
|
border-bottom: 1rpx solid var(--border-light);
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-summary {
|
||||||
|
padding: 40rpx 30rpx 30rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 122, 255, 0.05) 0%, rgba(0, 122, 255, 0.02) 100%);
|
||||||
|
border-bottom: 1rpx solid var(--border-light);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.summary-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-price {
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid rgba(0, 122, 255, 0.1);
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(0, 122, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
|
.method-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge-alipay {
|
||||||
|
background: linear-gradient(135deg, #1677ff 0%, #45a5ff 100%);
|
||||||
|
box-shadow: 0 8rpx 20rpx rgba(22, 119, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-radio {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border: 2rpx solid var(--gray-400);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
border-top: 1rpx solid var(--border-light);
|
||||||
|
|
||||||
|
.btn-apple {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background: var(--gray-200);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.btn { margin-top: var(--space-sm); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,33 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
|
<view v-if="mchList.length === 0 && !loading" class="empty-state">
|
||||||
|
<view class="empty-icon">📡</view>
|
||||||
|
<view class="empty-title">运营商列表为空</view>
|
||||||
|
<view class="empty-desc">当前设备暂无可切换的运营商</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="card" v-for="item in mchList" :key="item.iccid">
|
<view class="card" v-for="item in mchList" :key="item.iccid">
|
||||||
<view class="flex-row-sb mt-30">
|
<view class="flex-row-sb mt-30">
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<view class="logo">
|
<view class="logo">
|
||||||
<image :src="getLogo(item.category).logo" mode="aspectFit"></image>
|
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex-col-g20">
|
<view class="flex-col-g20">
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<view class="iccid">{{ item.iccid }}</view>
|
<view class="iccid">{{ item.iccid }}</view>
|
||||||
<view class="operator">
|
<view class="operator">
|
||||||
<up-tag type="success" size="mini">{{ getLogo(item.category).name }}</up-tag>
|
<up-tag type="success" size="mini">{{ getCarrier(item.carrier_type).name }}</up-tag>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<view class="operator">
|
|
||||||
<up-tag type="primary" size="mini" v-if="item.is_current">当前使用</up-tag>
|
|
||||||
</view>
|
|
||||||
<view class="operator">
|
<view class="operator">
|
||||||
<up-tag :type="item.real_name_status === 1 ? 'primary' : 'success'" size="mini">{{ item.real_name_status === 1 ? '已实名' : '未实名' }}</up-tag>
|
<up-tag :type="item.real_name_status === 1 ? 'primary' : 'success'" size="mini">{{ item.real_name_status === 1 ? '已实名' : '未实名' }}</up-tag>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="item.network_status" class="operator">
|
||||||
|
<up-tag type="info" size="mini">{{ item.network_status }}</up-tag>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="btn flex-row-g20 mt-30">
|
<view class="btn flex-row-g20 mt-30">
|
||||||
<up-button class="btn-apple btn-success" v-if="!item.is_current" type="success" @tap="switchOperator(item)" :loading="switching">
|
<up-button class="btn-apple btn-primary" v-if="item.is_current" type="primary" disabled>
|
||||||
|
当前使用
|
||||||
|
</up-button>
|
||||||
|
<up-button class="btn-apple btn-success" v-else type="success" @tap="switchOperator(item)" :loading="switching">
|
||||||
切换此运营商
|
切换此运营商
|
||||||
</up-button>
|
</up-button>
|
||||||
|
<up-button class="btn-apple btn-success" v-if="item.real_name_status !== 1" type="success" @tap="toReal(item)">
|
||||||
|
去实名
|
||||||
|
</up-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-overlay" v-if="showIccidModal" @tap="closeModal">
|
||||||
|
<view class="modal-content" @tap.stop>
|
||||||
|
<view class="modal-close" @tap="closeModal">✕</view>
|
||||||
|
<view class="modal-body">
|
||||||
|
<view class="modal-title">实名认证</view>
|
||||||
|
<view class="iccid-value">{{ currentModalIccid }}</view>
|
||||||
|
<view class="modal-button" @tap="doRealName">点击复制ICCID并跳转实名</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -35,37 +59,76 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue';
|
import { reactive, ref, onMounted } from 'vue';
|
||||||
import { deviceApi, assetApi } from '@/api/index.js';
|
import { assetApi, deviceApi, realnameApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
let mchList = reactive([]);
|
let mchList = reactive([]);
|
||||||
|
let loading = ref(false);
|
||||||
let switching = ref(false);
|
let switching = ref(false);
|
||||||
|
let showIccidModal = ref(false);
|
||||||
|
let currentModalIccid = ref('');
|
||||||
|
|
||||||
let opratorList = reactive([
|
const carrierMap = {
|
||||||
{ category: '124', logo: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500', name: '中国电信' },
|
CMCC: '中国移动',
|
||||||
{ category: '125', logo: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500', name: '中国联通' },
|
CUCC: '中国联通',
|
||||||
{ category: '126', logo: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182', name: '中国移动' }
|
CTCC: '中国电信',
|
||||||
]);
|
CBN: '中国广电'
|
||||||
|
};
|
||||||
|
|
||||||
const getLogo = (category) => {
|
const carrierLogoMap = {
|
||||||
const operator = opratorList.find(item => item.category === category);
|
CMCC: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182',
|
||||||
return operator || opratorList[0];
|
CUCC: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||||
|
CTCC: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500',
|
||||||
|
CBN: 'https://img1.baidu.com/it/u=3160680953,3401650303&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCarrier = (carrierType) => {
|
||||||
|
return {
|
||||||
|
name: carrierMap[carrierType] || '-',
|
||||||
|
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCards = async () => {
|
const loadCards = async () => {
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await assetApi.getInfo(userStore.state.identifier);
|
const assetData = await assetApi.getInfo(userStore.state.identifier);
|
||||||
|
|
||||||
|
// 判断是否为设备
|
||||||
|
if (assetData.asset_type === 'device') {
|
||||||
|
// 是设备,调用设备卡列表接口
|
||||||
|
const data = await deviceApi.getCards(userStore.state.identifier);
|
||||||
if (data.cards && data.cards.length > 0) {
|
if (data.cards && data.cards.length > 0) {
|
||||||
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
|
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
|
||||||
...card,
|
iccid: card.iccid,
|
||||||
category: card.carrier_name.includes('电信') ? '124' : card.carrier_name.includes('联通') ? '125' : '126'
|
carrier_type: card.carrier_type,
|
||||||
|
carrier_name: card.carrier_name,
|
||||||
|
is_current: card.is_active,
|
||||||
|
real_name_status: card.real_name_status,
|
||||||
|
network_status: card.network_status,
|
||||||
|
slot_position: card.slot_position
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 不是设备(单卡),直接使用 asset info 数据,只有一个卡
|
||||||
|
if (assetData.identifier) {
|
||||||
|
mchList.splice(0, mchList.length, {
|
||||||
|
iccid: assetData.identifier,
|
||||||
|
carrier_type: assetData.carrier_type,
|
||||||
|
carrier_name: assetData.carrier_name,
|
||||||
|
is_current: true,
|
||||||
|
real_name_status: assetData.real_name_status,
|
||||||
|
network_status: assetData.network_status,
|
||||||
|
slot_position: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载卡列表失败', e);
|
console.error('加载卡列表失败', e);
|
||||||
}
|
}
|
||||||
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchOperator = async (item) => {
|
const switchOperator = async (item) => {
|
||||||
@@ -80,6 +143,38 @@
|
|||||||
switching.value = false;
|
switching.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toReal = (card) => {
|
||||||
|
currentModalIccid.value = card.iccid;
|
||||||
|
showIccidModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
showIccidModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRealName = async () => {
|
||||||
|
const iccid = currentModalIccid.value;
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await realnameApi.getLink(userStore.state.identifier, iccid, { showError: false });
|
||||||
|
if (data.realname_url) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: iccid,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = data.realname_url;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取实名链接失败', e);
|
||||||
|
uni.showToast({ title: e?.msg || '获取实名链接失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCards();
|
loadCards();
|
||||||
});
|
});
|
||||||
@@ -113,9 +208,92 @@
|
|||||||
background: var(--gray-100);
|
background: var(--gray-100);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
&.btn-success {
|
&.btn-success {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
color: var(--text-inverse);
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
width: 620rpx;
|
||||||
|
max-width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx; left: 24rpx;
|
||||||
|
width: 48rpx; height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: var(--gray-600);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 60rpx 32rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iccid-value {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary);
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
min-height: 400rpx;
|
||||||
|
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||||
|
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||||
|
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
4862
pnpm-lock.yaml
generated
Normal file
4862
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': set this to true or false
|
||||||
|
core-js: set this to true or false
|
||||||
|
esbuild: set this to true or false
|
||||||
BIN
static/clear.png
Normal file
BIN
static/clear.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/data.png
Normal file
BIN
static/data.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
static/left.png
Normal file
BIN
static/left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/scan-code.png
Normal file
BIN
static/scan-code.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
static/tips.png
Normal file
BIN
static/tips.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
static/wechat.png
Normal file
BIN
static/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -5,7 +5,11 @@ const state = reactive({
|
|||||||
assetToken: '',
|
assetToken: '',
|
||||||
identifier: uni.getStorageSync('identifier') || '',
|
identifier: uni.getStorageSync('identifier') || '',
|
||||||
isDevice: false,
|
isDevice: false,
|
||||||
realNameStatus: 0
|
realNameStatus: 0,
|
||||||
|
userInfo: uni.getStorageSync('userInfo') || {
|
||||||
|
avatar: '',
|
||||||
|
nickname: ''
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useUserStore = () => {
|
export const useUserStore = () => {
|
||||||
@@ -31,11 +35,18 @@ export const useUserStore = () => {
|
|||||||
state.realNameStatus = status;
|
state.realNameStatus = status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setUserInfo = (userInfo) => {
|
||||||
|
Object.assign(state.userInfo, userInfo);
|
||||||
|
uni.setStorageSync('userInfo', userInfo);
|
||||||
|
};
|
||||||
|
|
||||||
const clearUser = () => {
|
const clearUser = () => {
|
||||||
state.token = '';
|
state.token = '';
|
||||||
state.assetToken = '';
|
state.assetToken = '';
|
||||||
|
state.userInfo = { avatar: '', nickname: '' };
|
||||||
uni.removeStorageSync('token');
|
uni.removeStorageSync('token');
|
||||||
uni.removeStorageSync('identifier');
|
uni.removeStorageSync('identifier');
|
||||||
|
uni.removeStorageSync('userInfo');
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -45,6 +56,7 @@ export const useUserStore = () => {
|
|||||||
setIdentifier,
|
setIdentifier,
|
||||||
setIsDevice,
|
setIsDevice,
|
||||||
setRealNameStatus,
|
setRealNameStatus,
|
||||||
|
setUserInfo,
|
||||||
clearUser
|
clearUser
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
2
uni.scss
2
uni.scss
@@ -74,3 +74,5 @@ $uni-color-subtitle: #555555; // 二级标题颜色
|
|||||||
$uni-font-size-subtitle:26px;
|
$uni-font-size-subtitle:26px;
|
||||||
$uni-color-paragraph: #3F536E; // 文章段落颜色
|
$uni-color-paragraph: #3F536E; // 文章段落颜色
|
||||||
$uni-font-size-paragraph:15px;
|
$uni-font-size-paragraph:15px;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export const BASE_URL = 'https://cmp-api.boss160.cn';
|
export const BASE_URL = import.meta.env.VITE_BASE_URL;
|
||||||
|
|
||||||
export const APP_TYPE = 'miniapp';
|
export const APP_TYPE = 'official_account';
|
||||||
|
|
||||||
export const APP_ID = 'wxea8c599fe100ce8a';
|
|
||||||
|
|||||||
206
utils/payment.js
Normal file
206
utils/payment.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
const ALIPAY_PENDING_REFRESH_KEY = 'pending_alipay_payment_refresh';
|
||||||
|
const ALIPAY_PAYMENT_DATA_KEY = 'pending_alipay_payment_data';
|
||||||
|
|
||||||
|
export const PAYMENT_REFRESH_TARGETS = {
|
||||||
|
PACKAGE_ORDER: 'package-order',
|
||||||
|
ORDER_LIST: 'order-list',
|
||||||
|
MY_WALLET: 'my-wallet'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidWechatPayConfig(payConfig) {
|
||||||
|
return !!payConfig &&
|
||||||
|
typeof payConfig.package === 'string' &&
|
||||||
|
payConfig.package.startsWith('prepay_id=');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlipayPaymentUrl(paymentLink) {
|
||||||
|
return getAlipayPaymentUrls(paymentLink).copyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlipayPaymentUrls(paymentLink) {
|
||||||
|
if (!paymentLink) {
|
||||||
|
return { copyUrl: '', qrUrl: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof paymentLink === 'string') {
|
||||||
|
return { copyUrl: paymentLink, qrUrl: paymentLink };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyUrl: paymentLink.copy_link || paymentLink.qr_link || '',
|
||||||
|
qrUrl: paymentLink.qr_link || paymentLink.copy_link || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAlipayPaymentLink(paymentLink) {
|
||||||
|
return !!getAlipayPaymentUrl(paymentLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPendingPaymentRefresh(target) {
|
||||||
|
if (!target) return;
|
||||||
|
uni.setStorageSync(ALIPAY_PENDING_REFRESH_KEY, {
|
||||||
|
target,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingPaymentRefresh(target) {
|
||||||
|
const pending = uni.getStorageSync(ALIPAY_PENDING_REFRESH_KEY);
|
||||||
|
if (!pending || pending.target !== target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.removeStorageSync(ALIPAY_PENDING_REFRESH_KEY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingAlipayPaymentData() {
|
||||||
|
return uni.getStorageSync(ALIPAY_PAYMENT_DATA_KEY) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePaymentUrl(value) {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(value)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlipayPaymentPageUrl(refreshTarget, payUrl, qrUrl) {
|
||||||
|
const params = [`t=${Date.now()}`];
|
||||||
|
if (refreshTarget) {
|
||||||
|
params.push(`from=${encodeURIComponent(refreshTarget)}`);
|
||||||
|
}
|
||||||
|
if (payUrl) {
|
||||||
|
const encodedPayUrl = encodePaymentUrl(payUrl);
|
||||||
|
if (encodedPayUrl) {
|
||||||
|
params.push(`pay64=${encodedPayUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (qrUrl) {
|
||||||
|
const encodedQrUrl = encodePaymentUrl(qrUrl);
|
||||||
|
if (encodedQrUrl) {
|
||||||
|
params.push(`qr64=${encodedQrUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/pages/alipay-payment/alipay-payment?${params.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlipayPaymentBrowserUrl(pageUrl) {
|
||||||
|
if (typeof window === 'undefined' || !window.location) {
|
||||||
|
return pageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origin, pathname } = window.location;
|
||||||
|
const pagePath = pageUrl.replace(/^\//, '');
|
||||||
|
const basePath = pathname.includes('/pages/')
|
||||||
|
? pathname.slice(0, pathname.indexOf('/pages/'))
|
||||||
|
: pathname.replace(/\/[^/]*$/, '');
|
||||||
|
|
||||||
|
return `${origin}${basePath}/${pagePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToAlipayPaymentPage(pageUrl, resolve, reject) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: pageUrl,
|
||||||
|
success: () => resolve({ redirecting: true }),
|
||||||
|
fail: () => {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: pageUrl,
|
||||||
|
success: () => resolve({ redirecting: true }),
|
||||||
|
fail: () => {
|
||||||
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
|
window.location.href = getAlipayPaymentBrowserUrl(pageUrl);
|
||||||
|
resolve({ redirecting: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('\u6253\u5f00\u652f\u4ed8\u5b9d\u652f\u4ed8\u9875\u5931\u8d25'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAlipayPayment(paymentLink, refreshTarget) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { copyUrl, qrUrl } = getAlipayPaymentUrls(paymentLink);
|
||||||
|
|
||||||
|
if (!copyUrl && !qrUrl) {
|
||||||
|
reject(new Error('\u652f\u4ed8\u94fe\u63a5\u65e0\u6548'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
markPendingPaymentRefresh(refreshTarget);
|
||||||
|
uni.setStorageSync(ALIPAY_PAYMENT_DATA_KEY, {
|
||||||
|
copyUrl,
|
||||||
|
qrUrl,
|
||||||
|
refreshTarget,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
navigateToAlipayPaymentPage(getAlipayPaymentPageUrl(refreshTarget, copyUrl, qrUrl), resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wechatH5Pay(payConfig) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!isValidWechatPayConfig(payConfig)) {
|
||||||
|
reject(new Error('\u652f\u4ed8\u53c2\u6570\u65e0\u6548'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof WeixinJSBridge === 'undefined') {
|
||||||
|
reject(new Error('\u8bf7\u5728\u5fae\u4fe1\u4e2d\u6253\u5f00'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeixinJSBridge.invoke('getBrandWCPayRequest', {
|
||||||
|
appId: payConfig.app_id,
|
||||||
|
timeStamp: payConfig.timestamp,
|
||||||
|
nonceStr: payConfig.nonce_str,
|
||||||
|
package: payConfig.package,
|
||||||
|
signType: payConfig.sign_type,
|
||||||
|
paySign: payConfig.pay_sign
|
||||||
|
}, function(res) {
|
||||||
|
if (res.err_msg === 'get_brand_wcpay_request:ok') {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: '\u652f\u4ed8\u6210\u529f'
|
||||||
|
});
|
||||||
|
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
|
||||||
|
reject({
|
||||||
|
cancelled: true,
|
||||||
|
message: '\u5df2\u53d6\u6d88\u652f\u4ed8'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
message: '\u652f\u4ed8\u5931\u8d25',
|
||||||
|
error: res
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPaymentToast(success, message) {
|
||||||
|
uni.showToast({
|
||||||
|
title: message || (success ? '\u652f\u4ed8\u6210\u529f' : '\u652f\u4ed8\u5931\u8d25'),
|
||||||
|
icon: success ? 'success' : 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePaymentError(error) {
|
||||||
|
if (error.cancelled) {
|
||||||
|
showPaymentToast(false, '\u5df2\u53d6\u6d88\u652f\u4ed8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPaymentToast(false, error.message || '\u652f\u4ed8\u5931\u8d25');
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { BASE_URL } from './env.js';
|
import { BASE_URL } from './env.js';
|
||||||
|
|
||||||
|
const showErrorToast = (message) => {
|
||||||
|
uni.showToast({
|
||||||
|
title: message || '请求失败,请稍后重试',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const request = (options) => {
|
const request = (options) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const token = uni.getStorageSync('token') || '';
|
const token = uni.getStorageSync('token') || '';
|
||||||
@@ -14,40 +22,32 @@ const request = (options) => {
|
|||||||
...options.header
|
...options.header
|
||||||
},
|
},
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
|
const responseData = res.data || {};
|
||||||
|
const { code, msg, data } = responseData;
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
uni.removeStorageSync('token');
|
uni.removeStorageSync('token');
|
||||||
uni.removeStorageSync('identifier');
|
uni.removeStorageSync('identifier');
|
||||||
uni.showToast({
|
showErrorToast(msg || '登录已过期,请重新登录');
|
||||||
title: '登录已过期,请重新登录',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.reLaunch({ url: '/pages/login/login' });
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
}, 2000);
|
}, 2000);
|
||||||
reject(res.data);
|
reject(responseData);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, msg, data } = res.data;
|
if (code === 0) {
|
||||||
|
|
||||||
if (code === 0 || code === 200) {
|
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} else {
|
return;
|
||||||
uni.showToast({
|
|
||||||
title: msg || '请求失败',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
reject(res.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.showError !== false) {
|
||||||
|
showErrorToast(msg);
|
||||||
|
}
|
||||||
|
reject(responseData);
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
uni.showToast({
|
showErrorToast('网络请求失败');
|
||||||
title: '网络请求失败',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
504
utils/shippingAddress.js
Normal file
504
utils/shippingAddress.js
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import { regionData } from 'element-china-area-data';
|
||||||
|
|
||||||
|
const GENERIC_CITY_LABELS = new Set([
|
||||||
|
'市辖区',
|
||||||
|
'县',
|
||||||
|
'自治区直辖县级行政区划',
|
||||||
|
'省直辖县级行政区划'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NAME_LABELS = ['收货人', '收件人', '联系人', '姓名'];
|
||||||
|
const PHONE_LABELS = ['手机号码', '手机号', '联系电话', '联系方式', '电话', '手机'];
|
||||||
|
const REGION_LABELS = ['所在地区', '地区'];
|
||||||
|
const DETAIL_LABELS = ['详细地址', '收货地址', '地址'];
|
||||||
|
const ALL_FIELD_LABELS = [
|
||||||
|
...new Set([...NAME_LABELS, ...PHONE_LABELS, ...REGION_LABELS, ...DETAIL_LABELS])
|
||||||
|
];
|
||||||
|
|
||||||
|
const PHONE_REG = /(?<!\d)(?:\+?86[-\s]?)?(1[3-9](?:[\s-]?\d){9})(?!\d)/;
|
||||||
|
const ADDRESS_SIGNAL_REG = /(省|市|区|县|旗|乡|镇|街道|大道|路|街|巷|弄|村|社区|小区|号|栋|单元|室|楼)/;
|
||||||
|
const FIELD_LABEL_PATTERN = createLabelPattern(ALL_FIELD_LABELS);
|
||||||
|
const STRIP_FIELD_LABEL_REG = new RegExp(
|
||||||
|
`(^|[\\s,,;;\\n])(?:${FIELD_LABEL_PATTERN})\\s*[::]`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
const provinceOptions = regionData.map(({ label, value }) => ({ label, value }));
|
||||||
|
|
||||||
|
const districtRecords = regionData.flatMap((province) =>
|
||||||
|
(province.children || []).flatMap((city) =>
|
||||||
|
(city.children || []).map((district) => {
|
||||||
|
const provinceAliases = createProvinceAliases(province.label);
|
||||||
|
const cityAliases = createCityAliases(getDisplayCityLabel(city.label));
|
||||||
|
const districtAliases = createDistrictAliases(district.label);
|
||||||
|
|
||||||
|
return {
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
district,
|
||||||
|
provinceAliases,
|
||||||
|
cityAliases,
|
||||||
|
districtAliases,
|
||||||
|
provinceSearchAliases: provinceAliases.map(normalizeForSearch).filter(Boolean),
|
||||||
|
citySearchAliases: cityAliases.map(normalizeForSearch).filter(Boolean),
|
||||||
|
districtSearchAliases: districtAliases.map(normalizeForSearch).filter(Boolean),
|
||||||
|
fullSearchLabels: buildFullSearchLabels(provinceAliases, cityAliases, districtAliases)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function escapeRegExp(value = '') {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLabelPattern(labels = []) {
|
||||||
|
return labels
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.length - left.length)
|
||||||
|
.map((label) => escapeRegExp(label))
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value = '') {
|
||||||
|
return String(value)
|
||||||
|
.replace(/\r/g, '\n')
|
||||||
|
.replace(/\u00a0/g, ' ')
|
||||||
|
.replace(/\t/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForSearch(value = '') {
|
||||||
|
return normalizeText(value).replace(/[\s,,.。;;::、\-_/]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripProvinceSuffix(label = '') {
|
||||||
|
return normalizeText(label).replace(
|
||||||
|
/(特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|省|市)$/u,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCitySuffix(label = '') {
|
||||||
|
return normalizeText(label).replace(/(自治州|地区|盟|市)$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDistrictSuffix(label = '') {
|
||||||
|
return normalizeText(label).replace(/(自治县|自治旗|区|县|旗|市)$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAliases(label = '', stripFn = null) {
|
||||||
|
const rawLabel = normalizeText(label);
|
||||||
|
const aliases = new Set([rawLabel]);
|
||||||
|
const simplifiedLabel = stripFn ? normalizeText(stripFn(rawLabel)) : '';
|
||||||
|
|
||||||
|
if (simplifiedLabel && simplifiedLabel !== rawLabel) {
|
||||||
|
aliases.add(simplifiedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...aliases].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProvinceAliases(label = '') {
|
||||||
|
return createAliases(label, stripProvinceSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCityAliases(label = '') {
|
||||||
|
if (!label) return [''];
|
||||||
|
return createAliases(label, stripCitySuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDistrictAliases(label = '') {
|
||||||
|
return createAliases(label, stripDistrictSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFullSearchLabels(provinceAliases = [], cityAliases = [], districtAliases = []) {
|
||||||
|
const fullLabels = new Set();
|
||||||
|
const normalizedCityAliases = cityAliases.length ? cityAliases : [''];
|
||||||
|
|
||||||
|
provinceAliases.forEach((provinceAlias) => {
|
||||||
|
normalizedCityAliases.forEach((cityAlias) => {
|
||||||
|
districtAliases.forEach((districtAlias) => {
|
||||||
|
const combinedLabel = normalizeForSearch(`${provinceAlias}${cityAlias}${districtAlias}`);
|
||||||
|
if (combinedLabel) {
|
||||||
|
fullLabels.add(combinedLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...fullLabels];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayCityLabel(label = '') {
|
||||||
|
return GENERIC_CITY_LABELS.has(label) ? '' : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProvinceByCode(provinceCode) {
|
||||||
|
return regionData.find((item) => item.value === provinceCode) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCityByCode(provinceCode, cityCode) {
|
||||||
|
const province = getProvinceByCode(provinceCode);
|
||||||
|
return province?.children?.find((item) => item.value === cityCode) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistrictByCode(provinceCode, cityCode, districtCode) {
|
||||||
|
const city = getCityByCode(provinceCode, cityCode);
|
||||||
|
return city?.children?.find((item) => item.value === districtCode) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCityOptions(provinceCode) {
|
||||||
|
const province = getProvinceByCode(provinceCode) || regionData[0];
|
||||||
|
return (province?.children || []).map(({ label, value }) => ({ label, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistrictOptions(provinceCode, cityCode) {
|
||||||
|
const province = getProvinceByCode(provinceCode) || regionData[0];
|
||||||
|
const city =
|
||||||
|
province?.children?.find((item) => item.value === cityCode) ||
|
||||||
|
province?.children?.[0] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return (city?.children || []).map(({ label, value }) => ({ label, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabelParts(regionCodes = []) {
|
||||||
|
const [provinceCode, cityCode, districtCode] = regionCodes;
|
||||||
|
const province = getProvinceByCode(provinceCode);
|
||||||
|
const city = getCityByCode(provinceCode, cityCode);
|
||||||
|
const district = getDistrictByCode(provinceCode, cityCode, districtCode);
|
||||||
|
|
||||||
|
if (!province || !city || !district) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayCityLabel = getDisplayCityLabel(city.label);
|
||||||
|
return [province.label, displayCityLabel, district.label].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFieldLabels(value = '') {
|
||||||
|
return normalizeText(value).replace(STRIP_FIELD_LABEL_REG, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupAddressText(value = '') {
|
||||||
|
return stripFieldLabels(value)
|
||||||
|
.replace(/[::]/g, ' ')
|
||||||
|
.replace(/[,,;;]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupName(value = '') {
|
||||||
|
return normalizeText(value)
|
||||||
|
.replace(/^[\s,,;;::]+/, '')
|
||||||
|
.replace(/[\s,,;;::]+$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestAliasInText(text = '', aliases = []) {
|
||||||
|
return aliases
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.length - left.length)
|
||||||
|
.find((alias) => alias && text.includes(alias)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPhone(rawText = '') {
|
||||||
|
const text = normalizeText(rawText);
|
||||||
|
const match = text.match(PHONE_REG);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
phone: '',
|
||||||
|
textWithoutPhone: text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = match[1].replace(/\D/g, '');
|
||||||
|
const phone = digits.length > 11 ? digits.slice(-11) : digits;
|
||||||
|
|
||||||
|
return {
|
||||||
|
phone,
|
||||||
|
textWithoutPhone: text.replace(match[0], ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestMatch(records, matcher) {
|
||||||
|
return records
|
||||||
|
.filter(matcher)
|
||||||
|
.sort((left, right) => {
|
||||||
|
return (
|
||||||
|
right.fullSearchLabels[0]?.length - left.fullSearchLabels[0]?.length ||
|
||||||
|
right.district.label.length - left.district.label.length
|
||||||
|
);
|
||||||
|
})[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchAnyAlias(searchText = '', aliases = []) {
|
||||||
|
return aliases.some((alias) => alias && searchText.includes(alias));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRegionMatch(rawText = '') {
|
||||||
|
const searchText = normalizeForSearch(rawText);
|
||||||
|
if (!searchText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let match = pickBestMatch(districtRecords, (record) => {
|
||||||
|
return record.fullSearchLabels.some((label) => searchText.includes(label));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
match = pickBestMatch(districtRecords, (record) => {
|
||||||
|
return (
|
||||||
|
matchAnyAlias(searchText, record.provinceSearchAliases) &&
|
||||||
|
matchAnyAlias(searchText, record.districtSearchAliases) &&
|
||||||
|
(!record.citySearchAliases.length || matchAnyAlias(searchText, record.citySearchAliases))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
match = pickBestMatch(districtRecords, (record) => {
|
||||||
|
return (
|
||||||
|
matchAnyAlias(searchText, record.citySearchAliases) &&
|
||||||
|
matchAnyAlias(searchText, record.districtSearchAliases)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionStartIndex(text, match) {
|
||||||
|
const candidates = [
|
||||||
|
findBestAliasInText(text, match.provinceAliases),
|
||||||
|
findBestAliasInText(text, match.cityAliases),
|
||||||
|
findBestAliasInText(text, match.districtAliases)
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((label) => text.indexOf(label))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
|
return candidates.length ? Math.min(...candidates) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMatchedRegion(addressText, match) {
|
||||||
|
let detailAddress = normalizeText(addressText);
|
||||||
|
const matchedProvince = findBestAliasInText(detailAddress, match.provinceAliases);
|
||||||
|
const matchedCity = findBestAliasInText(detailAddress, match.cityAliases);
|
||||||
|
const matchedDistrict = findBestAliasInText(detailAddress, match.districtAliases);
|
||||||
|
|
||||||
|
[matchedProvince, matchedCity, matchedDistrict]
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((label) => {
|
||||||
|
detailAddress = detailAddress.replace(label, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanupAddressText(detailAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNameFromText(rawText = '', textWithoutPhone = '', regionStartIndex = -1) {
|
||||||
|
const sourceText =
|
||||||
|
regionStartIndex > 0 ? textWithoutPhone.slice(0, regionStartIndex) : textWithoutPhone || rawText;
|
||||||
|
|
||||||
|
const directNameMatch = normalizeText(rawText).match(
|
||||||
|
/(?:收货人|收件人|联系人|姓名)\s*[::]?\s*([A-Za-z\u4e00-\u9fa5·]{2,20})/u
|
||||||
|
);
|
||||||
|
if (directNameMatch?.[1]) {
|
||||||
|
return directNameMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = cleanupAddressText(sourceText)
|
||||||
|
.replace(PHONE_REG, ' ')
|
||||||
|
.replace(/\d+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const tokens = cleaned.split(/\s+/).filter(Boolean);
|
||||||
|
const nameToken = tokens.find((token) => {
|
||||||
|
return !ADDRESS_SIGNAL_REG.test(token) && /^[A-Za-z\u4e00-\u9fa5·]{2,20}$/u.test(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nameToken || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAddressSource(textWithoutPhone = '', name = '', match = null) {
|
||||||
|
let addressSource = normalizeText(textWithoutPhone);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
addressSource = addressSource.replace(name, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const regionStartIndex = getRegionStartIndex(addressSource, match);
|
||||||
|
if (regionStartIndex >= 0) {
|
||||||
|
return addressSource.slice(regionStartIndex).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanupAddressText(addressSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRegionCodes(match) {
|
||||||
|
return [match.province.value, match.city.value, match.district.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineDetailParts(parts = []) {
|
||||||
|
return parts
|
||||||
|
.map((item) => cleanupAddressText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFieldValue(text = '', labels = []) {
|
||||||
|
if (!text || !labels.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelPattern = createLabelPattern(labels);
|
||||||
|
const fieldReg = new RegExp(
|
||||||
|
`(?:${labelPattern})\\s*[::]\\s*([\\s\\S]*?)(?=(?:[\\n,,]\\s*)?(?:${FIELD_LABEL_PATTERN})\\s*[::]|$)`,
|
||||||
|
'u'
|
||||||
|
);
|
||||||
|
const match = normalizeText(text).match(fieldReg);
|
||||||
|
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeText(match[1]).replace(/^[,,\s]+|[,,\s]+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLabeledFields(text = '') {
|
||||||
|
return {
|
||||||
|
name: extractFieldValue(text, NAME_LABELS),
|
||||||
|
phone: extractFieldValue(text, PHONE_LABELS),
|
||||||
|
region: extractFieldValue(text, REGION_LABELS),
|
||||||
|
detail: extractFieldValue(text, DETAIL_LABELS)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFieldBasedShippingText(text = '') {
|
||||||
|
const fields = extractLabeledFields(text);
|
||||||
|
const hasAnyField = Object.values(fields).some(Boolean);
|
||||||
|
|
||||||
|
if (!hasAnyField) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientName = cleanupName(fields.name);
|
||||||
|
const recipientPhone = extractPhone(fields.phone || text).phone;
|
||||||
|
const regionSource = normalizeText(fields.region);
|
||||||
|
const detailSource = normalizeText(fields.detail);
|
||||||
|
let regionMatch = findRegionMatch(regionSource || `${detailSource}${text}`);
|
||||||
|
|
||||||
|
if (!regionMatch) {
|
||||||
|
regionMatch = findRegionMatch(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailParts = [];
|
||||||
|
if (regionSource) {
|
||||||
|
const regionRemainder = regionMatch ? stripMatchedRegion(regionSource, regionMatch) : regionSource;
|
||||||
|
if (regionRemainder) {
|
||||||
|
detailParts.push(regionRemainder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (detailSource) {
|
||||||
|
detailParts.push(detailSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipient_name: recipientName,
|
||||||
|
recipient_phone: recipientPhone,
|
||||||
|
regionCodes: regionMatch ? formatRegionCodes(regionMatch) : [],
|
||||||
|
detailAddress: combineDetailParts(detailParts)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegionColumns(regionCodes = []) {
|
||||||
|
const provinceCode =
|
||||||
|
getProvinceByCode(regionCodes[0])?.value || provinceOptions[0]?.value || '';
|
||||||
|
const cityOptions = getCityOptions(provinceCode);
|
||||||
|
const cityCode =
|
||||||
|
cityOptions.find((item) => item.value === regionCodes[1])?.value ||
|
||||||
|
cityOptions[0]?.value ||
|
||||||
|
'';
|
||||||
|
const districtOptions = getDistrictOptions(provinceCode, cityCode);
|
||||||
|
const districtCode =
|
||||||
|
districtOptions.find((item) => item.value === regionCodes[2])?.value ||
|
||||||
|
districtOptions[0]?.value ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: [provinceOptions, cityOptions, districtOptions],
|
||||||
|
defaultIndex: [
|
||||||
|
provinceOptions.findIndex((item) => item.value === provinceCode),
|
||||||
|
cityOptions.findIndex((item) => item.value === cityCode),
|
||||||
|
districtOptions.findIndex((item) => item.value === districtCode)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegionDisplayText(regionCodes = []) {
|
||||||
|
return getLabelParts(regionCodes).join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeRecipientAddress(regionCodes = [], detailAddress = '') {
|
||||||
|
const regionText = getLabelParts(regionCodes).join('');
|
||||||
|
return `${regionText}${normalizeText(detailAddress)}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSavedRecipientAddress(rawAddress = '') {
|
||||||
|
const addressText = normalizeText(rawAddress);
|
||||||
|
if (!addressText) {
|
||||||
|
return {
|
||||||
|
regionCodes: [],
|
||||||
|
detailAddress: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findRegionMatch(addressText);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
regionCodes: [],
|
||||||
|
detailAddress: addressText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailAddress = stripMatchedRegion(addressText, match);
|
||||||
|
return {
|
||||||
|
regionCodes: formatRegionCodes(match),
|
||||||
|
detailAddress: detailAddress || addressText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShippingText(rawText = '') {
|
||||||
|
const text = normalizeText(rawText);
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
recipient_name: '',
|
||||||
|
recipient_phone: '',
|
||||||
|
regionCodes: [],
|
||||||
|
detailAddress: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldBasedResult = parseFieldBasedShippingText(text);
|
||||||
|
if (fieldBasedResult) {
|
||||||
|
return fieldBasedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phone, textWithoutPhone } = extractPhone(text);
|
||||||
|
const regionMatch = findRegionMatch(textWithoutPhone);
|
||||||
|
const regionStartIndex = regionMatch ? getRegionStartIndex(textWithoutPhone, regionMatch) : -1;
|
||||||
|
const recipientName = extractNameFromText(text, textWithoutPhone, regionStartIndex);
|
||||||
|
const addressSource = extractAddressSource(textWithoutPhone, recipientName, regionMatch);
|
||||||
|
const detailAddress = regionMatch
|
||||||
|
? stripMatchedRegion(addressSource, regionMatch)
|
||||||
|
: cleanupAddressText(addressSource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipient_name: recipientName,
|
||||||
|
recipient_phone: phone,
|
||||||
|
regionCodes: regionMatch ? formatRegionCodes(regionMatch) : [],
|
||||||
|
detailAddress
|
||||||
|
};
|
||||||
|
}
|
||||||
110
utils/wxsdk.js
Normal file
110
utils/wxsdk.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// utils/wxsdk.js —— 封装微信扫一扫 不用旧的了
|
||||||
|
import { wechatApi } from '@/api/index.js'
|
||||||
|
|
||||||
|
const getWx = () => window.jWeixin || window.wx
|
||||||
|
const entryUrl = window.__WX_ENTRY_URL__ || window.location.href.split('#')[0]
|
||||||
|
let configPromise = null
|
||||||
|
let configUrl = ''
|
||||||
|
let configReady = false
|
||||||
|
|
||||||
|
const isIOS = () => /iphone|ipad|ipod/i.test(navigator.userAgent)
|
||||||
|
|
||||||
|
const getConfigUrl = () => {
|
||||||
|
// iOS 微信 WebView 的 JS-SDK 签名 URL 需要使用 SPA 首次进入的地址。
|
||||||
|
if (isIOS()) {
|
||||||
|
return entryUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.location.href.split('#')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化微信 JS-SDK
|
||||||
|
*/
|
||||||
|
export async function initWxConfig() {
|
||||||
|
const wx = getWx()
|
||||||
|
if (!wx) {
|
||||||
|
throw new Error('微信 SDK 未加载')
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getConfigUrl()
|
||||||
|
console.log(url);
|
||||||
|
|
||||||
|
if (configReady && configUrl === url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configPromise && configUrl === url) {
|
||||||
|
return configPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
configUrl = url
|
||||||
|
configReady = false
|
||||||
|
|
||||||
|
configPromise = wechatApi.getJssdkConfig(url).then((data) => {
|
||||||
|
const { app_id, timestamp, nonce_str, signature } = data
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.config({
|
||||||
|
debug: false,
|
||||||
|
appId: app_id,
|
||||||
|
timestamp: timestamp,
|
||||||
|
nonceStr: nonce_str,
|
||||||
|
signature: signature,
|
||||||
|
jsApiList: ['scanQRCode']
|
||||||
|
})
|
||||||
|
wx.ready(() => {
|
||||||
|
console.log('微信 SDK 就绪')
|
||||||
|
configReady = true
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
wx.error((err) => {
|
||||||
|
console.error('wx.config 失败', err)
|
||||||
|
configReady = false
|
||||||
|
configPromise = null
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('获取微信签名失败', e)
|
||||||
|
configPromise = null
|
||||||
|
configReady = false
|
||||||
|
throw e
|
||||||
|
})
|
||||||
|
|
||||||
|
return configPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调起微信扫一扫
|
||||||
|
* @returns {Promise<string>} 扫码结果字符串
|
||||||
|
*/
|
||||||
|
export async function wxScan() {
|
||||||
|
await initWxConfig()
|
||||||
|
const wx = getWx()
|
||||||
|
if (!wx) {
|
||||||
|
throw new Error('微信 SDK 未加载')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.scanQRCode({
|
||||||
|
needResult: 1,
|
||||||
|
scanType: ['qrCode', 'barCode'],
|
||||||
|
success(res) {
|
||||||
|
console.log('扫码成功:', res)
|
||||||
|
resolve(res.resultStr)
|
||||||
|
},
|
||||||
|
fail(err) {
|
||||||
|
console.error('扫码失败:', err)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否在微信环境中
|
||||||
|
*/
|
||||||
|
export function isInWechat() {
|
||||||
|
return /micromessenger/i.test(navigator.userAgent)
|
||||||
|
}
|
||||||
@@ -1,6 +1,45 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import uni from '@dcloudio/vite-plugin-uni'
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const mpVerifyFiles = [
|
||||||
|
'MP_verify_0UoX8yClVgREjgPj.txt',
|
||||||
|
'MP_verify_4E1dVbOcZ9KzTzoc.txt',
|
||||||
|
'MP_verify_yU2Z8mp831jh6QX7.txt'
|
||||||
|
]
|
||||||
|
|
||||||
|
function copyMpVerifyFiles() {
|
||||||
|
return {
|
||||||
|
name: 'copy-mp-verify-files',
|
||||||
|
closeBundle() {
|
||||||
|
const outputDir = resolve(process.cwd(), 'dist/build/h5')
|
||||||
|
|
||||||
|
if (!existsSync(outputDir)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(outputDir, { recursive: true })
|
||||||
|
|
||||||
|
mpVerifyFiles.forEach((file) => {
|
||||||
|
const source = resolve(process.cwd(), file)
|
||||||
|
|
||||||
|
if (existsSync(source)) {
|
||||||
|
copyFileSync(source, resolve(outputDir, file))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [uni()],
|
plugins: [uni(), copyMpVerifyFiles()],
|
||||||
|
root: process.cwd(),
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
silenceDeprecations: ['legacy-js-api', 'import']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user