Compare commits

...

53 Commits

Author SHA1 Message Date
c341ef1aac feat: 重构移动端 H5 页面视觉设计
基于移动优先原则优化布局、排版、颜色和间距,提升设计感和专业度。

- 重构全局设计系统:颜色、阴影、间距、圆角、排版
- 优化 UserInfoCard:更大的头像和状态徽章设计
- 优化 DeviceStatusCard:信息层级和指标网格
- 优化 TrafficCard:进度条和数字排版
- 优化 FunctionCard:4列布局和触摸反馈
- 优化 WifiCard:列表设计和信息层次
- 优化 FloatingButton:去除渐变,纯色设计

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:18:25 +08:00
sexygoat
727c4126dd fix: status
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-04-15 17:16:40 +08:00
sexygoat
6916be02da fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 17:07:39 +08:00
sexygoat
68af449610 fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-15 17:04:42 +08:00
sexygoat
b3fcdba80f fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 17:00:49 +08:00
sexygoat
7a78e05570 fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-15 16:56:30 +08:00
sexygoat
17e4b84e4c fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-15 16:50:35 +08:00
sexygoat
427b25cabd fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 16:45:43 +08:00
sexygoat
af1dc68681 fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 16:43:49 +08:00
sexygoat
9c5778b706 fix: network_status
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 16:41:36 +08:00
sexygoat
9797de4202 fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-15 16:32:12 +08:00
sexygoat
dc3a37f60f fix: order-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-15 16:22:06 +08:00
sexygoat
8bf89dbe57 fix: status
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 16:10:20 +08:00
sexygoat
6da9353203 fix: status
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 16:01:14 +08:00
sexygoat
a30a689dba fix: login params
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-15 15:51:23 +08:00
sexygoat
5113520b46 fix: package-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 15:27:45 +08:00
sexygoat
43a13bf863 fix: package-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-15 15:23:39 +08:00
sexygoat
d3a40f0d77 fix: package-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 15:17:12 +08:00
sexygoat
181d24a388 fix: package-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 49s
2026-04-15 15:04:26 +08:00
sexygoat
0be233e0c0 fix: package-list
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-15 14:54:17 +08:00
sexygoat
1477444599 fix: login page
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-15 14:42:41 +08:00
sexygoat
9c56aa7e30 fix: 订单时间
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 48s
2026-04-15 14:10:10 +08:00
sexygoat
7ef8615103 fix: 修改充值提醒,钱包显示
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-15 12:09:00 +08:00
sexygoat
1336b86fc2 fix: 修改强充
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-15 09:34:20 +08:00
sexygoat
e79d17058a fix: 套餐列表支付
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-14 17:50:43 +08:00
sexygoat
ea7e2b7362 fix: back
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-14 17:47:24 +08:00
sexygoat
3f0a04f2ce fix: 临时注释卡的语音白名单,通话
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-14 17:23:29 +08:00
sexygoat
3f792d642a feat: 新增空状态
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-14 17:02:16 +08:00
sexygoat
eb81a5cb21 fix: 用户头像
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m32s
2026-04-14 14:24:00 +08:00
sexygoat
e9634cd4bf fix: 弹窗
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 44s
2026-04-14 12:32:20 +08:00
sexygoat
7a7d63d327 fix: 弹窗
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 46s
2026-04-14 12:29:54 +08:00
sexygoat
b39dff0293 fix: 钱包
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-14 12:26:47 +08:00
sexygoat
bd9f3e6141 fix: 支付/钱包
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 44s
2026-04-14 12:17:51 +08:00
sexygoat
5e8ff94b7a fix: 支付/钱包
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 44s
2026-04-14 12:12:57 +08:00
sexygoat
1db7ea9b7c fix: 支付
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-14 11:58:47 +08:00
sexygoat
f634af1d65 fix: 套餐列表支付, 非设备显示phone
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 44s
2026-04-14 11:48:03 +08:00
sexygoat
52b9027b4f fix: 联系客服,后台管理
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 45s
2026-04-14 11:28:28 +08:00
sexygoat
4ce6b8ddc1 fix: 资产套餐历史,设备换货
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-14 11:11:24 +08:00
sexygoat
5fa2b6dacf fix: 修改index数据来源
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 44s
2026-04-14 10:50:48 +08:00
sexygoat
1a407924de fix: 修改index数据来源
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-14 10:31:25 +08:00
sexygoat
0b8dd7af00 feat: 新增微信网页授权
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 49s
2026-04-14 09:50:19 +08:00
36a1e94d34 添加微信公众号域名验证文件
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 43s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-13 11:27:35 +08:00
sexygoat
243f795e3e feat: 新增授权文件
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-04-13 11:13:47 +08:00
sexygoat
8bdd3a2d2a fix: 登录UI
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 49s
2026-04-11 18:02:57 +08:00
8d2b3094c6 添加 less 依赖
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 42s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 16:05:10 +08:00
26ac4da0ad 设置 UNI_INPUT_DIR=. 支持 HBuilderX 根目录结构
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 42s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 16:01:43 +08:00
fdfe79fe36 固定 types 版本并恢复 --legacy-peer-deps
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 47s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:56:47 +08:00
6090fb255a 固定 @dcloudio 到参考项目已验证版本 3.0.0-4080720251210001
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 4s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:54:00 +08:00
3b501d304b 锁定 @dcloudio 包到 3.x(Vue 3 版本)
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 8s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:48:46 +08:00
b247e1873d 移除 --legacy-peer-deps,让 npm 自动安装 peer dependencies
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 4s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:45:46 +08:00
038334630e 补全 @dcloudio 构建依赖
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 24s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:43:18 +08:00
3fd3f9dba0 添加 vue 依赖修复构建缺少 @vue/shared 的问题
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 26s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:41:40 +08:00
2ffdba83cc 移除 Dockerfile 中的 uvm 交互式步骤
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 22s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:35:00 +08:00
37 changed files with 15681 additions and 1626 deletions

View File

@@ -1,7 +1,14 @@
{
"permissions": {
"allow": [
"Bash(git add:*)"
"Bash(git add:*)",
"Read(//Users/break/.agents/skills/impeccable/**)",
"Bash(node .agents/skills/impeccable/scripts/cleanup-deprecated.mjs)",
"Bash(node *)",
"Bash(npm run *)",
"Bash(curl -s http://localhost:5173)",
"Bash(git checkout *)",
"Bash(git commit -m ' *)"
],
"deny": [],
"ask": []

30
.impeccable.md Normal file
View File

@@ -0,0 +1,30 @@
# Impeccable Design Context
## Design Context
### Users
- **Who**: Regular consumers on mobile who purchased a portable WiFi/MiFi device
- **Context**: Using the app on their phone to check data usage, manage their device settings, top up balance, and ensure connectivity while traveling
- **Job to be done**: Quick access to device status, data usage, and account management with minimal friction
### Brand Personality
- **Voice**: Clean, confident, trustworthy
- **Tone**: Fast and efficient, modern and sleek, friendly and helpful, reliable and secure
- **3-word personality**: Modern · Trustworthy · Effortless
- **Emotional goals**: Users should feel in control of their connectivity, confident their device is working, and that managing it is simple
### Aesthetic Direction
- **Visual tone**: Minimal and refined — clean layouts with purposeful use of space
- **References**: Consumer electronics (Apple, Tile, Anker) + Minimalist app interfaces
- **Anti-references**: Cluttered admin dashboards, AI slop aesthetics, legacy carrier apps, overly playful designs
- **Theme**: Light mode only — bright, clean, accessible
- **Colors**: Blue-based palette (iOS-like primary blue #0A84FF) with the existing color system as a base
### Design Principles
1. **Clarity first**: Every element earns its place. No decorative clutter.
2. **Confidence through space**: Generous whitespace creates premium feel. Tight grouping shows related content.
3. **Light and bright**: Light theme only. White surfaces, not gray. Blue accent for primary actions.
4. **Progressive disclosure**: Simple by default, reveal complexity on demand (e.g., WiFi settings behind a tap).
5. **Fast interactions**: Optimistic UI. Updates feel instant. Loading states are minimal.
6. **Typography hierarchy**: Bold headings, readable body. Clear contrast between levels.
7. **Trust signals**: Online/offline status, battery level, data usage — at a glance, always visible.

569
App.vue
View File

@@ -8,85 +8,86 @@
@import "uview-plus/index.scss";
:root {
/* 主色 */
--primary: #0A84FF;
/* 主色 - iOS Blue 家族 */
--primary: #007AFF;
--primary-light: #5AC8FA;
--primary-dark: #0066CC;
--primary-dark: #0056CC;
--primary-tint: rgba(0, 122, 255, 0.08);
/* 辅助色 */
--success: #30D158;
--success-light: rgba(48, 209, 88, 0.12);
--warning: #FF9F0A;
--warning-light: rgba(255, 159, 10, 0.12);
--danger: #FF453A;
--danger-light: rgba(255, 69, 58, 0.12);
--info: #5E5CE6;
/* 语义色 */
--success: #34C759;
--success-light: rgba(52, 199, 89, 0.1);
--warning: #FF9500;
--warning-light: rgba(255, 149, 0, 0.1);
--danger: #FF3B30;
--danger-light: rgba(255, 59, 48, 0.1);
--info: #5856D6;
/* 扩展色 */
--purple: #BF5AF2;
--pink: #FF375F;
--teal: #64D2FF;
--indigo: #5E5CE6;
/* 中性色 - 灰阶 */
--gray-100: #F2F2F7;
--gray-200: #E5E5EA;
--gray-300: #D1D1D6;
--gray-400: #C7C7CC;
--gray-500: #AEAEB2;
--gray-600: #8E8E93;
--gray-700: #636366;
--gray-800: #3C3C43;
--gray-900: #000000;
/* 背景色 */
--bg-primary: #FFFFFF;
--bg-secondary: #F2F2F7;
--bg-tertiary: #FFFFFF;
--bg-grouped: #F2F2F7;
/* 中性色 - 微妙的蓝色底色 */
--gray-50: #F8F9FC;
--gray-100: #EEF1F5;
--gray-200: #D9DDE6;
--gray-300: #B8C0D0;
--gray-400: #8E95A8;
--gray-500: #6B7280;
--gray-600: #4B5563;
--gray-700: #374151;
--gray-800: #1F2937;
--gray-900: #111827;
/* 文字色 */
--text-primary: #000000;
--text-secondary: #3C3C43;
--text-tertiary: rgba(60, 60, 67, 0.6);
--text-quaternary: rgba(60, 60, 67, 0.3);
--text-primary: var(--gray-900);
--text-secondary: var(--gray-600);
--text-tertiary: var(--gray-400);
--text-inverse: #FFFFFF;
/* 阴影 */
--shadow-small: 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
--shadow-medium: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
--shadow-large: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
--shadow-extra-large: 0 16rpx 48rpx rgba(0, 0, 0, 0.16);
/* 背景色 */
--bg-base: #F5F7FA;
--bg-surface: #FFFFFF;
--bg-elevated: #FFFFFF;
/* 阴影 - 更细腻 */
--shadow-xs: 0 1rpx 2rpx rgba(0, 0, 0, 0.04);
--shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
--shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8rpx 32rpx rgba(0, 0, 0, 0.10);
--shadow-xl: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
/* 圆角 */
--radius-small: 8rpx;
--radius-medium: 16rpx;
--radius-large: 24rpx;
--radius-extra-large: 32rpx;
--radius-xs: 6rpx;
--radius-sm: 10rpx;
--radius-md: 16rpx;
--radius-lg: 24rpx;
--radius-xl: 32rpx;
--radius-full: 9999rpx;
/* 间距 */
--space-xs: 8rpx;
--space-sm: 16rpx;
--space-md: 24rpx;
--space-lg: 32rpx;
--space-xl: 40rpx;
--space-2xl: 48rpx;
/* 间距 - 4pt 基准 */
--space-1: 4rpx;
--space-2: 8rpx;
--space-3: 12rpx;
--space-4: 16rpx;
--space-5: 20rpx;
--space-6: 24rpx;
--space-8: 32rpx;
--space-10: 40rpx;
--space-12: 48rpx;
--space-16: 64rpx;
/* 过渡 */
--transition-fast: 0.2s ease-out;
--transition-normal: 0.3s ease-out;
--transition-slow: 0.5s ease-out;
/* 边框色 */
--border-color: var(--gray-200);
--border-light: var(--gray-100);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 0.15s;
--duration-normal: 0.25s;
--duration-slow: 0.4s;
}
* {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "MiSans", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
box-sizing: border-box;
font-family: "MiSans", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
ul, li {
@@ -95,29 +96,31 @@
page {
color: var(--text-primary);
background-color: var(--bg-secondary);
background-color: var(--bg-base);
min-height: 100vh;
}
.container {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 30rpx 20rpx;
gap: var(--space-4);
padding: var(--space-5) var(--space-4);
max-width: 750rpx;
margin: 0 auto;
}
.card {
background: var(--bg-primary);
border-radius: var(--radius-medium);
padding: var(--space-md);
border: 1rpx solid var(--border-color);
transition: all var(--transition-fast);
background: var(--bg-surface);
border-radius: var(--radius-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
}
.card-elevated {
box-shadow: var(--shadow-large);
transform: translateY(-4rpx);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-interactive {
@@ -126,103 +129,120 @@
-webkit-tap-highlight-color: transparent;
}
.title {
/* 排版系统 */
.text-display {
font-size: 48rpx;
font-weight: 700;
font-size: 34rpx;
line-height: 1.2;
line-height: 1.1;
letter-spacing: -0.03em;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.subtitle {
.text-title {
font-size: 36rpx;
font-weight: 600;
font-size: 28rpx;
line-height: 1.3;
color: var(--text-secondary);
line-height: 1.2;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.body {
.text-heading {
font-size: 30rpx;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.text-body {
font-size: 28rpx;
font-weight: 400;
font-size: 28rpx;
line-height: 1.4;
line-height: 1.5;
color: var(--text-secondary);
}
.caption {
font-weight: 500;
.text-caption {
font-size: 24rpx;
line-height: 1.3;
font-weight: 500;
line-height: 1.4;
color: var(--text-tertiary);
}
.mt-xs { margin-top: var(--space-xs); }
.mt-sm { margin-top: var(--space-sm); }
.mt-md { margin-top: var(--space-md); }
.mt-lg { margin-top: var(--space-lg); }
.mt-xl { margin-top: var(--space-xl); }
.mt-30 { margin-top: 30rpx; }
.mt-20 { margin-top: 20rpx; }
.mb-xs { margin-bottom: var(--space-xs); }
.mb-sm { margin-bottom: var(--space-sm); }
.mb-md { margin-bottom: var(--space-md); }
.mb-lg { margin-bottom: var(--space-lg); }
.mb-xl { margin-bottom: var(--space-xl); }
.p-xs { padding: var(--space-xs); }
.p-sm { padding: var(--space-sm); }
.p-md { padding: var(--space-md); }
.p-lg { padding: var(--space-lg); }
.p-xl { padding: var(--space-xl); }
.flex-row { display: flex; align-items: center; }
.flex-row-g8 { display: flex; align-items: center; gap: var(--space-xs); }
.flex-row-g16 { display: flex; align-items: center; gap: var(--space-sm); }
.flex-row-g20 { display: flex; align-items: center; gap: 20rpx; }
.flex-row-g24 { display: flex; align-items: center; gap: var(--space-md); }
.flex-row-sb { display: flex; align-items: center; justify-content: space-between; }
.flex-row-center { display: flex; align-items: center; justify-content: center; }
.flex-col { display: flex; flex-direction: column; }
.flex-col-g8 { display: flex; flex-direction: column; gap: var(--space-xs); }
.flex-col-g16 { display: flex; flex-direction: column; gap: var(--space-sm); }
.flex-col-g20 { display: flex; flex-direction: column; gap: 20rpx; }
.flex-col-g24 { display: flex; flex-direction: column; gap: var(--space-md); }
.flex-col-center-g20 { display: flex; flex-direction: column; gap: 20rpx; align-items: center; justify-content: center; }
.flex-col-center { display: flex; flex-direction: column; align-items: center; justify-content: center; }
.w-100 { width: 100%; }
.h-100 { height: 100%; }
.interactive {
transition: all var(--transition-normal);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.button-apple {
background: var(--primary);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-medium);
padding: var(--space-sm) var(--space-md);
font-weight: 600;
font-size: 28rpx;
transition: all var(--transition-normal);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.tag-apple {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
background: var(--gray-100);
color: var(--text-secondary);
border-radius: var(--radius-small);
.text-label {
font-size: 22rpx;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.02em;
color: var(--text-tertiary);
text-transform: uppercase;
}
/* 间距 */
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-5 { margin-top: var(--space-5); }
.mt-6 { margin-top: var(--space-6); }
.mt-8 { margin-top: var(--space-8); }
.mt-10 { margin-top: var(--space-10); }
.mb-1 { margin-bottom: var(--space-1); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-5 { margin-bottom: var(--space-5); }
.mb-6 { margin-bottom: var(--space-6); }
.mb-8 { margin-bottom: var(--space-8); }
.p-4 { padding: var(--space-4); }
.p-5 { padding: var(--space-5); }
.p-6 { padding: var(--space-6); }
/* Flex */
.flex-row { display: flex; align-items: center; }
.flex-row-g2 { display: flex; align-items: center; gap: var(--space-2); }
.flex-row-g3 { display: flex; align-items: center; gap: var(--space-3); }
.flex-row-g4 { display: flex; align-items: center; gap: var(--space-4); }
.flex-row-g5 { display: flex; align-items: center; gap: var(--space-5); }
.flex-row-g6 { display: flex; align-items: center; gap: var(--space-6); }
.flex-row-sb { display: flex; align-items: center; justify-content: space-between; }
.flex-row-center { display: flex; align-items: center; justify-content: center; }
.flex-row-end { display: flex; align-items: center; justify-content: flex-end; }
.flex-col { display: flex; flex-direction: column; }
.flex-col-g2 { display: flex; flex-direction: column; gap: var(--space-2); }
.flex-col-g3 { display: flex; flex-direction: column; gap: var(--space-3); }
.flex-col-g4 { display: flex; flex-direction: column; gap: var(--space-4); }
.flex-col-g5 { display: flex; flex-direction: column; gap: var(--space-5); }
.flex-col-g6 { display: flex; flex-direction: column; gap: var(--space-6); }
.flex-col-center { display: flex; flex-direction: column; align-items: center; justify-content: center; }
.w-full { width: 100%; }
.h-full { height: 100%; }
/* 交互状态 */
.interactive {
transition: all var(--duration-normal) var(--ease-out);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* 标签 */
.tag {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: 22rpx;
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.01em;
&.tag-default {
background: var(--gray-100);
color: var(--gray-600);
}
&.tag-success {
background: var(--success-light);
@@ -230,7 +250,7 @@
}
&.tag-primary {
background: rgba(10, 132, 255, 0.12);
background: var(--primary-tint);
color: var(--primary);
}
@@ -245,36 +265,207 @@
}
}
.progress-apple {
/* 进度条 */
.progress {
width: 100%;
height: 8rpx;
background: var(--gray-200);
border-radius: var(--radius-small);
height: 10rpx;
background: var(--gray-100);
border-radius: var(--radius-full);
overflow: hidden;
position: relative;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
border-radius: var(--radius-small);
transition: width var(--transition-normal);
background: var(--primary);
border-radius: var(--radius-full);
transition: width var(--duration-slow) var(--ease-out);
}
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
user-select: none;
-webkit-tap-highlight-color: transparent;
padding: var(--space-4) var(--space-5);
font-size: 28rpx;
background: var(--gray-100);
color: var(--text-primary);
&.btn-primary {
background: var(--primary);
color: var(--text-inverse);
}
&.btn-success {
background: var(--success);
color: var(--text-inverse);
}
&.btn-warning {
background: var(--warning);
color: var(--text-inverse);
}
&.btn-danger {
background: var(--danger);
color: var(--text-inverse);
}
&.btn-ghost {
background: transparent;
color: var(--primary);
}
&.btn-mini {
min-height: 52rpx;
padding: 0 var(--space-4);
font-size: 24rpx;
border-radius: var(--radius-sm);
}
&.btn-large {
min-height: 88rpx;
padding: 0 var(--space-8);
font-size: 32rpx;
border-radius: var(--radius-lg);
}
&.btn-disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
}
.btn-group {
display: flex;
gap: var(--space-3);
.btn {
flex: 1;
}
&.btn-group-vertical {
flex-direction: column;
}
}
/* 兼容旧类名 */
.mt-xs { margin-top: var(--space-2); }
.mt-sm { margin-top: var(--space-4); }
.mt-md { margin-top: var(--space-6); }
.mt-lg { margin-top: var(--space-8); }
.mt-xl { margin-top: var(--space-10); }
.mt-30 { margin-top: 30rpx; }
.mt-20 { margin-top: 20rpx; }
.mb-xs { margin-bottom: var(--space-2); }
.mb-sm { margin-bottom: var(--space-4); }
.mb-md { margin-bottom: var(--space-6); }
.mb-lg { margin-bottom: var(--space-8); }
.mb-xl { margin-bottom: var(--space-10); }
.p-xs { padding: var(--space-2); }
.p-sm { padding: var(--space-4); }
.p-md { padding: var(--space-6); }
.p-lg { padding: var(--space-8); }
.p-xl { padding: var(--space-10); }
.flex-row-g8 { display: flex; align-items: center; gap: var(--space-2); }
.flex-row-g16 { display: flex; align-items: center; gap: var(--space-4); }
.flex-row-g20 { display: flex; align-items: center; gap: 20rpx; }
.flex-row-g24 { display: flex; align-items: center; gap: var(--space-6); }
.flex-col-g8 { display: flex; flex-direction: column; gap: var(--space-2); }
.flex-col-g16 { display: flex; flex-direction: column; gap: var(--space-4); }
.flex-col-g20 { display: flex; flex-direction: column; gap: 20rpx; }
.flex-col-g24 { display: flex; flex-direction: column; gap: var(--space-6); }
/* 背景色 */
.bg-primary { background-color: var(--bg-surface); }
.bg-secondary { background-color: var(--bg-base); }
.bg-success { background-color: var(--success); }
.bg-warning { background-color: var(--warning); }
.bg-danger { background-color: var(--danger); }
/* 文字色 */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-inverse { color: var(--text-inverse); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
/* 旧标签兼容 */
.tag-apple {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
background: var(--gray-100);
color: var(--gray-600);
border-radius: var(--radius-full);
font-size: 22rpx;
font-weight: 600;
letter-spacing: 0.01em;
&.tag-success {
background: var(--success-light);
color: var(--success);
}
&.tag-primary {
background: var(--primary-tint);
color: var(--primary);
}
&.tag-warning {
background: var(--warning-light);
color: var(--warning);
}
&.tag-danger {
background: var(--danger-light);
color: var(--danger);
}
}
/* 旧按钮兼容 */
.button-apple {
background: var(--primary);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
font-weight: 600;
font-size: 28rpx;
transition: all var(--duration-normal) var(--ease-out);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.btn-apple {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-small);
border-radius: var(--radius-sm);
font-weight: 500;
font-size: 26rpx;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--duration-fast) var(--ease-out);
user-select: none;
-webkit-tap-highlight-color: transparent;
padding: 20rpx;
padding: var(--space-5);
background: var(--gray-100);
color: var(--text-primary);
@@ -299,56 +490,44 @@
}
&.btn-secondary {
background: var(--gray-300);
color: var(--text-secondary);
background: var(--gray-200);
color: var(--gray-700);
}
&.btn-mini {
min-height: 40rpx;
padding: 0 16rpx;
min-height: 52rpx;
padding: 0 var(--space-4);
font-size: 24rpx;
border-radius: 6rpx;
border-radius: var(--radius-xs);
}
&.btn-large {
min-height: 64rpx;
padding: 0 32rpx;
min-height: 88rpx;
padding: 0 var(--space-8);
font-size: 32rpx;
border-radius: var(--radius-medium);
border-radius: var(--radius-md);
}
&.btn-disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
}
.btn-group {
display: flex;
gap: 12rpx;
/* 旧进度条兼容 */
.progress-apple {
width: 100%;
height: 10rpx;
background: var(--gray-100);
border-radius: var(--radius-full);
overflow: hidden;
.btn-apple {
flex: 1;
}
&.btn-group-vertical {
flex-direction: column;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: var(--radius-full);
transition: width var(--duration-slow) var(--ease-out);
}
}
/* 通用背景色 */
.bg-primary { background-color: var(--bg-primary); }
.bg-secondary { background-color: var(--bg-secondary); }
.bg-success { background-color: var(--success); }
.bg-warning { background-color: var(--warning); }
.bg-danger { background-color: var(--danger); }
/* 通用文字色 */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-inverse { color: var(--text-inverse); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
</style>

View File

@@ -15,9 +15,6 @@ RUN npm config set registry https://registry.npmmirror.com
# 复制 package.json利用 Docker 缓存)
COPY package.json ./
# 使用 uvm 自动安装最新兼容的 @dcloudio 包版本
RUN npx @dcloudio/uvm@latest
# 安装所有依赖
RUN npm install --legacy-peer-deps
@@ -48,6 +45,7 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# 从构建阶段复制构建产物
COPY --from=builder /build/dist/build/h5 /usr/share/nginx/html
COPY MP_verify_yU2Z8mp831jh6QX7.txt /usr/share/nginx/html/MP_verify_yU2Z8mp831jh6QX7.txt
# 暴露端口
EXPOSE 80

View File

@@ -0,0 +1 @@
yU2Z8mp831jh6QX7

View File

@@ -3,10 +3,14 @@ import { APP_TYPE } from '@/utils/env.js';
export const orderApi = {
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({
url: '/api/c/v1/orders',
method: 'GET',
data: { identifier, page, page_size, payment_status }
data
});
},
@@ -23,5 +27,20 @@ export const orderApi = {
method: 'POST',
data: { identifier, package_ids, app_type: APP_TYPE }
});
},
pay(order_id, payment_method, password) {
const data = {
payment_method,
app_type: APP_TYPE
};
if (password) {
data.password = password;
}
return request({
url: `/api/c/v1/orders/${order_id}/pay`,
method: 'POST',
data
});
}
};

View File

@@ -34,6 +34,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 = {}) {
return request({
url: '/api/c/v1/wallet/transactions',

View File

@@ -1,48 +1,64 @@
<template>
<view class="card device-status-card">
<view class="card-header mb-md flex-row-sb">
<view class="title">设备信息</view>
<view class="tag-apple tag-success">{{ getOperator(deviceInfo.currentIccid) }}</view>
</view>
<view class="device-info flex-col-g16">
<view class="info-row flex-row-sb">
<view class="info-label">
<view class="subtitle">当前卡: {{ deviceInfo.currentIccid }}</view>
</view>
<view class="info-values flex-row-g20" @tap="$emit('authentication')">
<view class="tag-apple" :class="isRealName ? 'tag-success' : 'tag-warning'">
{{ isRealName ? "已实名" : "未实名" }}
</view>
</view>
<view class="device-status-card">
<view class="card-header">
<text class="text-heading">设备信息</text>
<view class="operator-badge">
<text class="operator-name">中国电信</text>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">
<view class="subtitle">到期时间{{ deviceInfo.expireDate }}</view>
</view>
<view class="info-values flex-row-g20">
<view class="tag-apple tag-primary">{{ deviceInfo.statusStr }}</view>
</view>
<view class="status-row">
<view class="status-item">
<text class="status-label">当前卡</text>
<text class="status-value iccid">{{ deviceInfo.currentIccid }}</text>
</view>
<view class="status-item">
<text class="status-label">实名状态</text>
<view class="auth-badge" :class="isRealName ? 'auth-done' : 'auth-pending'" @tap="$emit('authentication')">
<text>{{ isRealName ? '已实名' : '未实名' }}</text>
<text class="arrow"></text>
</view>
</view>
</view>
<view class="device-metrics flex-row-sb mt-md">
<view class="metric-item flex-col-center">
<view class="caption mb-xs">信号强度</view>
<view class="metric-value">{{ getSignalText(deviceInfo.rssi) }}</view>
<view class="status-row">
<view class="status-item full-width">
<text class="status-label">到期时间</text>
<text class="status-value expire">{{ deviceInfo.expireDate }}</text>
</view>
<view class="metric-item flex-col-center">
<view class="caption mb-xs">设备电量</view>
<view class="metric-value">{{ deviceInfo.battery }} %</view>
</view>
<view class="metrics-grid">
<view class="metric-card">
<view class="metric-icon signal">
<image src="/static/signal.png" mode="aspectFit" v-if="false"></image>
<text class="metric-icon-text">📶</text>
</view>
<text class="metric-label">信号强度</text>
<text class="metric-value">{{ getSignalText(deviceInfo.rssi) }}</text>
</view>
<view class="metric-item flex-col-center">
<view class="caption mb-xs">连接数量</view>
<view class="metric-value">{{ deviceInfo.connCnt }} </view>
<view class="metric-card">
<view class="metric-icon battery">
<text class="metric-icon-text">🔋</text>
</view>
<text class="metric-label">设备电量</text>
<text class="metric-value">{{ deviceInfo.battery }}%</text>
</view>
<view class="metric-card">
<view class="metric-icon connections">
<text class="metric-icon-text">📱</text>
</view>
<text class="metric-label">连接数量</text>
<text class="metric-value">{{ deviceInfo.connCnt }}/{{ deviceInfo.max_clients }}</text>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
import { computed } from 'vue';
const props = defineProps({
deviceInfo: { type: Object, default: () => ({}) },
isRealName: { type: Boolean, default: false },
opratorList: { type: Array, default: () => [] }
@@ -50,27 +66,152 @@
defineEmits(['authentication']);
const getOperator = (iccid) => {
return '中国电信';
};
const getSignalText = (rssi) => rssi || '强';
</script>
<style scoped lang="scss">
.device-status-card {
.info-row {
padding: var(--space-sm) 0;
border-bottom: 1rpx solid var(--gray-200);
&:last-child { border-bottom: none; }
.info-label { min-width: 200rpx; }
.info-values { flex: 1; justify-content: flex-end; }
}
.device-metrics {
background: var(--gray-100);
border-radius: var(--radius-small);
padding: 20rpx;
.metric-item { flex: 1; }
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--space-4);
border-bottom: 1rpx solid var(--gray-100);
margin-bottom: var(--space-4);
}
.operator-badge {
padding: var(--space-1) var(--space-3);
background: var(--primary-tint);
border-radius: var(--radius-full);
}
.operator-name {
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
& + .status-row {
border-top: 1rpx solid var(--gray-50);
}
}
</style>
.status-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
&.full-width {
flex: 1;
}
}
.status-label {
font-size: 24rpx;
color: var(--text-tertiary);
}
.status-value {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
&.iccid {
font-family: "SF Mono", "Menlo", monospace;
font-size: 24rpx;
}
&.expire {
color: var(--primary);
}
}
.auth-badge {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: 24rpx;
font-weight: 600;
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out);
&:active {
transform: scale(0.96);
}
&.auth-done {
background: var(--success-light);
color: var(--success);
}
&.auth-pending {
background: var(--warning-light);
color: var(--warning);
}
.arrow {
font-size: 28rpx;
line-height: 1;
}
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
margin-top: var(--space-5);
padding-top: var(--space-5);
border-top: 1rpx solid var(--gray-100);
}
.metric-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
background: var(--gray-50);
border-radius: var(--radius-md);
}
.metric-icon {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
.metric-icon-text {
font-size: 32rpx;
}
}
.metric-label {
font-size: 22rpx;
color: var(--text-tertiary);
text-align: center;
}
.metric-value {
font-size: 28rpx;
font-weight: 700;
color: var(--text-primary);
text-align: center;
}
</style>

View File

@@ -1,13 +1,17 @@
<template>
<view class="floating-service-card" @click="handleClick">
<view class="service-left">
<image class="service-icon" src="/static/link.png" mode="aspectFit"></image>
<view class="service-content">
<text class="service-title">需要帮助</text>
<text class="service-desc">专属客服在线解答</text>
<view class="service-card" @click="handleClick">
<view class="service-inner">
<view class="service-left">
<view class="service-icon-wrap">
<image class="service-icon" src="/static/link.png" mode="aspectFit"></image>
</view>
<view class="service-content">
<text class="service-title">需要帮助</text>
<text class="service-desc">专属客服在线解答</text>
</view>
</view>
<view class="service-btn">立即联系</view>
</view>
<view class="service-btn">立即联系</view>
</view>
</template>
@@ -20,56 +24,71 @@
</script>
<style scoped lang="scss">
.floating-service-card {
.service-card {
position: fixed;
bottom: 20rpx;
left: 20rpx;
right: 20rpx;
height: 120rpx;
background: #fff;
border-radius: 60rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
bottom: calc(var(--space-5) + env(safe-area-inset-bottom));
left: var(--space-4);
right: var(--space-4);
z-index: 999;
}
.service-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx 0 24rpx;
z-index: 1000;
padding: var(--space-4) var(--space-5);
background: var(--bg-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.service-left {
display: flex;
align-items: center;
gap: 20rpx;
gap: var(--space-4);
}
.service-icon {
width: 60rpx;
height: 60rpx;
}
.service-icon-wrap {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-tint);
border-radius: var(--radius-md);
}
.service-content {
display: flex;
flex-direction: column;
gap: 4rpx;
.service-icon {
width: 44rpx;
height: 44rpx;
}
.service-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.service-content {
display: flex;
flex-direction: column;
gap: 2rpx;
}
.service-desc {
font-size: 22rpx;
color: var(--text-tertiary);
}
}
.service-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
}
.service-desc {
font-size: 24rpx;
color: var(--text-tertiary);
line-height: 1.3;
}
.service-btn {
padding: 16rpx 32rpx;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: #fff;
padding: var(--space-3) var(--space-5);
background: var(--primary);
color: var(--text-inverse);
font-size: 26rpx;
font-weight: 600;
border-radius: 40rpx;
border-radius: var(--radius-full);
line-height: 1;
}
</style>

View File

@@ -1,91 +1,22 @@
<template>
<view class="card function-card">
<view class="card-header">
<view class="title">功能菜单</view>
<view class="header-actions">
<button class="btn-apple btn-primary btn-mini" @tap="$emit('sync')">运营商数据同步</button>
</view>
<view class="card-header mb-5">
<text class="text-heading">功能服务</text>
</view>
<view class="function-grid">
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'authentication')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
<view
v-for="item in displayFunctions"
:key="item.key"
class="function-item interactive"
:class="{ 'is-disabled': item.disabled }"
@click="handleItemClick(item)"
role="button"
tabindex="0"
>
<view class="function-icon-wrap">
<image class="function-icon" :src="item.icon" mode="aspectFit" :alt="item.name"></image>
</view>
<view :class="['function-name', realNameStatus === '已实名' ? 'text-primary' : '']">
{{ realNameStatus || '实名认证' }}
</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'package-order')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
</view>
<view class="function-name">套餐订购</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'order-list')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
</view>
<view class="function-name">我的订单</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'back')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/back.png" mode="aspectFit" alt="后台管理"></image>
</view>
<view class="function-name">后台管理</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'switch')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
</view>
<view class="function-name">切换运营商</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'asset-package')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/shop.png" mode="aspectFit" alt="资产套餐历史"></image>
</view>
<view class="function-name">资产套餐历史</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'bind')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
</view>
<view class="function-name">绑定手机号</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'change-phone')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
</view>
<view class="function-name">更换手机号</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'device-exchange')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/change.png" mode="aspectFit" alt="设备换货"></image>
</view>
<view class="function-name">设备换货</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'wallet')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/wallet.png" mode="aspectFit" alt="我的钱包"></image>
</view>
<view class="function-name">我的钱包</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/recover.png" mode="aspectFit" alt="重启设备"></image>
</view>
<view class="function-name">重启设备</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'restart')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/restart.png" mode="aspectFit" alt="恢复出厂"></image>
</view>
<view class="function-name">恢复出厂</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'out')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
</view>
<view class="function-name">退出登录</view>
<text class="function-name" :class="{ 'text-primary-color': item.highlight }">{{ item.name }}</text>
</view>
</view>
@@ -94,50 +25,114 @@
</template>
<script setup>
defineProps({
realNameStatus: { type: String, default: '' },
alreadyBindPhone: { type: Boolean, default: false },
isDevice: { type: Boolean, default: true }
import { computed } from 'vue';
const props = defineProps({
realNameStatus: {
type: String,
default: ''
},
alreadyBindPhone: {
type: Boolean,
default: false
},
isDevice: {
type: Boolean,
default: true
}
});
defineEmits(['enter', 'sync']);
const emit = defineEmits(['enter', 'sync']);
const functions = computed(() => {
const list = [
{ key: 'authentication', name: props.realNameStatus || '实名认证', icon: '/static/authentication.png', action: 'authentication', highlight: props.realNameStatus === '已实名' },
{ key: 'sync', name: '运营数据同步', icon: '/static/data.png', action: 'sync' },
{ key: 'package-order', name: '套餐订购', icon: '/static/shop.png', action: 'package-order' },
{ key: 'order-list', name: '我的订单', icon: '/static/order.png', action: 'order-list' },
{ key: 'back', name: '后台管理', icon: '/static/back.png', action: 'back', deviceOnly: true },
{ key: 'switch', name: '切换运营商', icon: '/static/change.png', action: 'switch', deviceOnly: true },
{ key: 'asset-package', name: '资产套餐历史', icon: '/static/shop.png', action: 'asset-package' },
{ key: 'bind', name: props.alreadyBindPhone ? '已绑定' : '绑定手机号', icon: '/static/bind-phone.png', action: 'bind', highlight: props.alreadyBindPhone, disabled: props.alreadyBindPhone },
{ key: 'change-phone', name: '更换手机号', icon: '/static/change-phone.png', action: 'change-phone' },
{ key: 'device-exchange', name: '设备换货', icon: '/static/change.png', action: 'device-exchange', deviceOnly: true },
{ key: 'wallet', name: '我的钱包', icon: '/static/wallet.png', action: 'wallet' },
{ key: 'recover', name: '重启设备', icon: '/static/recover.png', action: 'recover', deviceOnly: true },
{ key: 'restart', name: '恢复出厂', icon: '/static/restart.png', action: 'restart', deviceOnly: true },
{ key: 'out', name: '退出登录', icon: '/static/out.png', action: 'out' }
];
return list.filter(item => !item.deviceOnly || props.isDevice);
});
const displayFunctions = computed(() => functions.value);
const handleItemClick = (item) => {
if (item.disabled) return;
if (item.action === 'bind' && props.alreadyBindPhone) return;
if (item.action === 'sync') {
emit('sync');
} else {
emit('enter', item.action);
}
};
</script>
<style scoped lang="scss">
.function-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.function-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
width: 100%;
.function-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx;
.function-icon {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
image { width: 100%; height: 100%; }
}
.function-name {
font-size: 28rpx;
font-weight: 500;
color: var(--text-primary);
text-align: center;
margin-top: 15rpx;
}
}
padding-bottom: var(--space-4);
border-bottom: 1rpx solid var(--gray-100);
}
}
.text-primary { color: var(--primary) !important; }
</style>
.function-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
padding-top: var(--space-5);
}
.function-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease-out);
&:active {
background: var(--gray-50);
}
&.is-disabled {
opacity: 0.5;
pointer-events: none;
}
}
.function-icon-wrap {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--gray-50);
border-radius: var(--radius-md);
}
.function-icon {
width: 48rpx;
height: 48rpx;
}
.function-name {
font-size: 22rpx;
font-weight: 500;
color: var(--text-secondary);
text-align: center;
line-height: 1.3;
white-space: nowrap;
}
</style>

View File

@@ -1,37 +1,41 @@
<template>
<view class="card traffic-card">
<view class="card-header flex-row-sb mb-lg">
<view class="title">本月流量</view>
<view class="usage-percent">
<text class="title">{{ usedFlowPercent }}</text>
<text class="caption">%</text>
<view class="traffic-card">
<view class="card-header">
<text class="text-heading">本月流量</text>
<view class="usage-indicator">
<text class="usage-value">{{ usedFlowPercent }}</text>
<text class="usage-unit">%</text>
</view>
</view>
<view class="progress-section mb-lg">
<view class="progress-apple">
<view class="progress-fill" :style="{width: usedFlowPercent + '%'}"></view>
<view class="progress-section">
<view class="progress-track">
<view class="progress-fill" :style="{ width: usedFlowPercent + '%' }"></view>
</view>
</view>
<view class="traffic-stats flex-row-sb">
<view class="stat-item flex-col-center">
<view class="caption mb-xs">已使用</view>
<view class="stat-value-container">
<text class="stat-value">{{ formatDataSize(deviceInfo.totalBytesCnt) }}</text>
<text class="stat-unit">{{ isDevice ? 'GB' : 'MB' }}</text>
<view class="traffic-stats">
<view class="stat-item">
<text class="stat-label">已使用</text>
<view class="stat-value-wrap">
<text class="stat-value">{{ usedValue }}</text>
<text class="stat-unit">{{ usedUnit }}</text>
</view>
</view>
<view class="stat-item flex-col-center">
<view class="caption mb-xs">总流量</view>
<view class="stat-value-container">
<text class="stat-value">{{ formatDataSize(deviceInfo.flowSize) }}</text>
<text class="stat-unit">{{ isDevice ? 'GB' : 'MB' }}</text>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">总流量</text>
<view class="stat-value-wrap">
<text class="stat-value">{{ totalValue }}</text>
<text class="stat-unit">{{ totalUnit }}</text>
</view>
</view>
<view class="stat-item flex-col-center">
<view class="caption mb-xs">剩余</view>
<view class="stat-value-container">
<text class="stat-value">{{ formatDataSize(deviceInfo.flowSize - deviceInfo.totalBytesCnt) }}</text>
<text class="stat-unit">{{ isDevice ? 'GB' : 'MB' }}</text>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">剩余</text>
<view class="stat-value-wrap">
<text class="stat-value remain">{{ remainValue }}</text>
<text class="stat-unit">{{ remainUnit }}</text>
</view>
</view>
</view>
@@ -39,42 +43,210 @@
</template>
<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({
deviceInfo: { type: Object, default: () => ({}) },
isDevice: { type: Boolean, default: true }
identifier: { type: String, default: '' }
});
const usedFlowPercent = computed(() => {
if (props.deviceInfo.flowSize && props.deviceInfo.totalBytesCnt) {
return (props.deviceInfo.totalBytesCnt / props.deviceInfo.flowSize * 100).toFixed(2);
const emit = defineEmits(['packageLoaded']);
const userStore = useUserStore();
// 流量数据
const usedMB = ref(0);
const totalMB = ref(0);
const remainMB = ref(0);
// 格式化流量数据MB 转 GB
const formatTraffic = (mb) => {
if (!mb || mb === 0) {
return { value: '0.00', unit: 'MB' };
}
return '0';
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 && usedMB.value) {
const percent = (usedMB.value / totalMB.value * 100);
return Math.min(percent, 100).toFixed(1);
}
return '0.0';
});
const formatDataSize = (size) => {
if (!size) return '0';
const num = parseFloat(size);
if (num < 0.01) return '0';
return num.toFixed(2);
// 加载套餐流量数据
const loadPackageData = async () => {
const identifier = props.identifier || userStore.state.identifier;
if (!identifier) return;
try {
const data = await assetApi.getPackageHistory(identifier, 1, 10, { status: 1 });
if (data.items && data.items.length > 0) {
// 只有一个生效中的套餐
const activePackage = data.items[0];
if (activePackage.enable_virtual_data) {
// 启用虚流量
usedMB.value = activePackage.virtual_used_mb || 0;
totalMB.value = activePackage.virtual_limit_mb || 0;
remainMB.value = activePackage.virtual_remain_mb || 0;
} else {
// 未启用虚流量,使用真流量
usedMB.value = activePackage.data_usage_mb || 0;
totalMB.value = activePackage.data_limit_mb || 0;
remainMB.value = totalMB.value - usedMB.value;
}
// 将套餐信息(包括到期时间)传递给父组件
emit('packageLoaded', activePackage);
} else {
// 没有生效中的套餐
usedMB.value = 0;
totalMB.value = 0;
remainMB.value = 0;
emit('packageLoaded', null);
}
} catch (e) {
console.error('加载套餐流量数据失败', e);
}
};
// 监听 identifier 变化
watch(() => props.identifier || userStore.state.identifier, (newVal) => {
if (newVal) {
loadPackageData();
}
}, { immediate: true });
</script>
<style scoped lang="scss">
.traffic-card {
.usage-percent {
display: flex;
align-items: baseline;
gap: 4rpx;
.title { font-size: 35rpx; font-weight: 700; margin-right: 5rpx; }
}
.traffic-stats {
background: var(--gray-100);
border-radius: var(--radius-small);
padding: 20rpx;
margin-top: 16rpx;
.stat-item { flex: 1; }
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-5);
}
.usage-indicator {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.usage-value {
font-size: 48rpx;
font-weight: 700;
color: var(--primary);
letter-spacing: -0.02em;
line-height: 1;
}
.usage-unit {
font-size: 28rpx;
font-weight: 600;
color: var(--text-tertiary);
}
.progress-section {
margin-bottom: var(--space-5);
}
.progress-track {
height: 14rpx;
background: var(--gray-100);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: var(--radius-full);
transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.traffic-stats {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
background: var(--gray-50);
border-radius: var(--radius-lg);
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.stat-divider {
width: 1rpx;
height: 60rpx;
background: var(--gray-200);
}
.stat-label {
font-size: 24rpx;
color: var(--text-tertiary);
}
.stat-value-wrap {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
&.remain {
color: var(--success);
}
}
</style>
.stat-unit {
font-size: 22rpx;
font-weight: 500;
color: var(--text-tertiary);
}
</style>

View File

@@ -1,41 +1,166 @@
<template>
<view class="card user-info-card interactive">
<view class="flex-row-g20">
<view class="user-info-card">
<view class="card-main">
<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="用户头像" />
<image :src="avatarUrl" mode="aspectFill" alt="用户头像" />
</view>
<view class="user-details flex-col-g8">
<view class="flex-row-g20">
<view class="title">{{ currentCardNo }}</view>
<view class="user-details">
<view class="user-id">
<text class="device-id">{{ currentCardNo || '-' }}</text>
<view class="status-badge" :class="isOnline ? 'status-online' : 'status-offline'">
<text class="status-dot"></text>
<text class="status-text">{{ isOnline ? '在线' : '离线' }}</text>
</view>
</view>
<view class="user-meta">
<text class="meta-text" v-if="isDevice">IMEI{{ deviceInfo.imei || '-' }}</text>
<text class="meta-text" v-else>手机号{{ deviceInfo.bound_phone || '-' }}</text>
</view>
<view class="caption">到期时间{{ deviceInfo.expireDate }}</view>
</view>
<view class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
{{ onlineStatus }}
</view>
<view class="card-footer" v-if="!isDevice">
<view class="network-badge" :class="networkStatus == 1 ? 'network-ok' : 'network-stop'">
{{ networkStatus == 1 ? '正常' : '停机' }}
</view>
</view>
</view>
</template>
<script setup>
defineProps({
import { computed } from 'vue';
import { useUserStore } from '@/store/index.js';
const props = defineProps({
currentCardNo: { type: String, default: '' },
deviceInfo: { type: Object, default: () => ({}) },
onlineStatus: { type: String, default: '离线' }
onlineStatus: { type: String, default: '离线' },
networkStatus: { type: [String, Number], default: '离线' },
isDevice: { type: Boolean, default: true }
});
const userStore = useUserStore();
const defaultAvatar = 'https://img1.baidu.com/it/u=2462918877,1866131262&fm=253&fmt=auto&app=138&f=JPEG?w=506&h=500';
const avatarUrl = computed(() => {
return userStore.state.userInfo.avatar || defaultAvatar;
});
const isOnline = computed(() => {
return props.onlineStatus === '在线';
});
</script>
<style scoped lang="scss">
.user-info-card {
color: var(--text-primary);
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: var(--radius-medium);
overflow: hidden;
border: 2rpx solid var(--border-light);
image { width: 100%; height: 100%; object-fit: cover; }
}
.user-details { flex: 1; }
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-md);
}
</style>
.card-main {
display: flex;
align-items: center;
gap: var(--space-4);
}
.user-avatar {
width: 96rpx;
height: 96rpx;
border-radius: var(--radius-lg);
overflow: hidden;
border: 4rpx solid var(--gray-100);
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.user-details {
flex: 1;
min-width: 0;
}
.user-id {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.device-id {
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 4rpx 12rpx;
border-radius: var(--radius-full);
font-size: 20rpx;
font-weight: 600;
}
.status-online {
background: var(--success-light);
color: var(--success);
}
.status-offline {
background: var(--warning-light);
color: var(--warning);
}
.status-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
}
.status-text {
line-height: 1;
}
.user-meta {
margin-top: var(--space-2);
}
.meta-text {
font-size: 24rpx;
color: var(--text-tertiary);
}
.card-footer {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1rpx solid var(--gray-100);
}
.network-badge {
display: inline-flex;
align-items: center;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
font-size: 24rpx;
font-weight: 600;
}
.network-ok {
background: var(--success-light);
color: var(--success);
}
.network-stop {
background: var(--danger-light);
color: var(--danger);
}
</style>

View File

@@ -1,25 +1,29 @@
<template>
<view class="card wifi-card">
<view class="card-header flex-row-sb">
<view class="title">WiFi配置</view>
<view class="btn-group">
<button class="btn-apple btn-primary btn-mini" @tap="$emit('modify')">修改</button>
</view>
<view class="wifi-card">
<view class="card-header">
<text class="text-heading">WiFi配置</text>
<button class="btn btn-mini btn-primary" @tap="$emit('modify')">修改</button>
</view>
<view class="wifi-info flex-col-g16 mt-30">
<view class="wifi-item flex-row-sb">
<view class="wifi-details flex-col-g8">
<view class="caption">网络名称</view>
<view class="subtitle">{{ deviceInfo.ssidName }}</view>
<view class="wifi-list">
<view class="wifi-item">
<view class="wifi-icon-wrap">
<text class="wifi-icon-text">📶</text>
</view>
<button class="btn-apple btn-primary btn-mini" @tap="$emit('copy', deviceInfo.ssidName)">复制</button>
<view class="wifi-content">
<text class="wifi-label">网络名称</text>
<text class="wifi-value">{{ deviceInfo.ssidName }}</text>
</view>
<button class="btn-copy" @tap="$emit('copy', deviceInfo.ssidName)">复制</button>
</view>
<view class="wifi-item flex-row-sb">
<view class="wifi-details flex-col-g8">
<view class="caption">连接密码</view>
<view class="subtitle">{{ deviceInfo.ssidPwd }}</view>
<view class="wifi-item">
<view class="wifi-icon-wrap">
<text class="wifi-icon-text">🔑</text>
</view>
<button class="btn-apple btn-primary btn-mini" @tap="$emit('copy', deviceInfo.ssidPwd)">复制</button>
<view class="wifi-content">
<text class="wifi-label">连接密码</text>
<text class="wifi-value">{{ deviceInfo.ssidPwd }}</text>
</view>
<button class="btn-copy" @tap="$emit('copy', deviceInfo.ssidPwd)">复制</button>
</view>
</view>
</view>
@@ -35,13 +39,86 @@
<style scoped lang="scss">
.wifi-card {
.wifi-item {
padding: var(--space-md);
background: var(--gray-100);
border-radius: var(--radius-medium);
margin-bottom: var(--space-sm);
&:last-child { margin-bottom: 0; }
.wifi-details { flex: 1; }
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--space-4);
border-bottom: 1rpx solid var(--gray-100);
margin-bottom: var(--space-4);
}
.wifi-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.wifi-item {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
background: var(--gray-50);
border-radius: var(--radius-md);
}
.wifi-icon-wrap {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.wifi-icon-text {
font-size: 32rpx;
}
.wifi-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rpx;
}
.wifi-label {
font-size: 22rpx;
color: var(--text-tertiary);
}
.wifi-value {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-copy {
padding: var(--space-2) var(--space-4);
background: var(--bg-surface);
color: var(--primary);
font-size: 24rpx;
font-weight: 600;
border: none;
border-radius: var(--radius-full);
line-height: 1.5;
flex-shrink: 0;
&::after {
border: none;
}
}
</style>
</style>

View File

@@ -257,7 +257,7 @@ url: /api/c/v1/asset/info
"asset_id": 1,
"identifier": "862639070804960", // 当前卡
"virtual_no": "862639070804960",
"status": 1, // 状态 到期时间右边那个
"status": 1, // 状态 到期时间右边那个
"real_name_status": 0, // 实名状态
"carrier_name": "", // 运营商
"generation": "1",
@@ -408,6 +408,7 @@ GET /api/c/v1/asset/package-history?identifier=1234567890&page=1&page_size=10
"package_usage_id": 5001,
"priority": 1,
"status": 1,
"enable_virtual_data": true,
"status_name": "生效中",
"usage_type": "single_card",
"virtual_limit_mb": 20480,
@@ -448,7 +449,11 @@ data.items[]
- virtual_ratio虚流量比例(real/virtual)
- virtual_remain_mb剩余虚流量(MB)按virtual_ratio换算
- virtual_used_mb已用虚流量(MB)按virtual_ratio换算
- enable_virtual_data是否启用虚流量
* 如果启用了虚流量那么 本月流量展示的就是 套餐虚流量总量 剩余虚流量 已用虚流量 只是值用这些,已使用,总流量,剩余不要改
* 如果没有启用那就是 套餐真流量总量 已用真流量 剩余真流量(自己计算) 然后如果大于或者等于1024MB 转换成 GB
* 本月流量哪里需要调用这个接口,带上参数status: 1:生效中 只会有一个生效的
*
## 3.3 资产可购套餐列表
URL

View 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
**适用范围:** 微信小程序支付

80
docs/浅色深色.md Normal file
View 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)

View File

@@ -43,7 +43,9 @@
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
"sdkConfigs" : {
"payment" : {}
}
}
},
/* */

6970
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,22 @@
{
"scripts": {
"dev:h5": "uni",
"build:h5": "uni build -p h5"
"build:h5": "UNI_INPUT_DIR=. uni build -p h5"
},
"devDependencies": {
"@dcloudio/types": "*",
"@dcloudio/vite-plugin-uni": "*",
"@dcloudio/types": "3.4.19",
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
"less": "^4.0.0",
"sass": "^1.63.2",
"sass-loader": "^10.4.1",
"vite": "^5.0.0"
"vite": "5.2.8"
},
"dependencies": {
"@dcloudio/uni-app": "*",
"@dcloudio/uni-components": "*",
"@dcloudio/uni-h5": "*",
"@dcloudio/uni-app": "3.0.0-4080720251210001",
"@dcloudio/uni-components": "3.0.0-4080720251210001",
"@dcloudio/uni-h5": "3.0.0-4080720251210001",
"vue": "^3.4.0",
"clipboard": "^2.0.11",
"dayjs": "^1.11.19",
"uview-plus": "^3.6.29"

View File

@@ -47,7 +47,8 @@
{
"path": "pages/bind/bind",
"style": {
"navigationBarTitleText": "绑定手机号"
"navigationBarTitleText": "绑定手机号",
"navigationStyle": "custom"
}
},
{

View File

@@ -47,16 +47,16 @@
<view class="flow-title">流量信息</view>
<view class="flow-stats">
<view class="flow-item">
<view class="flow-label">已用真流量</view>
<view class="flow-value">{{ formatMB(item.data_usage_mb) }}</view>
<view class="flow-label">使</view>
<view class="flow-value">{{ getUsedFlow(item) }}</view>
</view>
<view class="flow-item">
<view class="flow-label">量真流量</view>
<view class="flow-value">{{ formatMB(item.data_limit_mb) }}</view>
<view class="flow-label">总流量</view>
<view class="flow-value">{{ getTotalFlow(item) }}</view>
</view>
<view class="flow-item">
<view class="flow-label">剩余虚流量</view>
<view class="flow-value">{{ formatMB(item.virtual_remain_mb) }}</view>
<view class="flow-label">剩余</view>
<view class="flow-value">{{ getRemainFlow(item) }}</view>
</view>
</view>
<view class="progress-section">
@@ -101,16 +101,63 @@
};
const formatMB = (mb) => {
if (!mb) return '0';
if (!mb && mb !== 0) return '0 MB';
if (mb >= 1024) {
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.data_usage_mb || 0);
}
};
// 获取总流量
const getTotalFlow = (item) => {
if (item.enable_virtual_data) {
// 启用虚流量,显示虚流量总量
return formatMB(item.virtual_limit_mb || 0);
} else {
// 未启用虚流量,显示真流量总量
return formatMB(item.data_limit_mb || 0);
}
};
// 获取剩余流量
const getRemainFlow = (item) => {
if (item.enable_virtual_data) {
// 启用虚流量,显示剩余虚流量
return formatMB(item.virtual_remain_mb || 0);
} else {
// 未启用虚流量,计算剩余真流量
const remain = (item.data_limit_mb || 0) - (item.data_usage_mb || 0);
return formatMB(remain);
}
};
// 获取使用百分比
const getUsagePercent = (item) => {
if (!item.data_limit_mb) return 0;
return Math.min((item.data_usage_mb / item.data_limit_mb) * 100, 100).toFixed(2);
let used, total;
if (item.enable_virtual_data) {
// 启用虚流量
used = item.virtual_used_mb || 0;
total = item.virtual_limit_mb || 0;
} else {
// 未启用虚流量
used = item.data_usage_mb || 0;
total = item.data_limit_mb || 0;
}
if (!total) return 0;
return Math.min((used / total) * 100, 100).toFixed(2);
};
const formatDate = (dateStr) => {

View File

@@ -1,5 +1,11 @@
<template>
<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="flex-row-g20">
<view class="logo">
@@ -32,12 +38,13 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { assetApi, realnameApi } from '@/api/index.js';
import { assetApi, deviceApi, realnameApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
let list = reactive([]);
let loading = ref(false);
let showIccidModal = ref(false);
let currentModalIccid = ref('');
let currentCard = ref(null);
@@ -54,18 +61,43 @@
};
const loadCards = async () => {
loading.value = true;
try {
const data = await assetApi.getInfo(userStore.state.identifier);
if (data.cards && data.cards.length > 0) {
list.splice(0, list.length, ...data.cards.map(card => ({
iccid: card.iccid,
category: card.carrier_name.includes('电信') ? '124' : card.carrier_name.includes('联通') ? '125' : '126',
isRealName: card.real_name_status === 1
})));
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) {
list.splice(0, list.length, ...data.cards.map(card => ({
iccid: card.iccid,
category: getCarrierCategory(card.carrier_name),
isRealName: card.real_name_status === 1
})));
}
} else {
// 不是设备(单卡),直接使用 asset info 数据
if (assetData.identifier) {
list.splice(0, list.length, {
iccid: assetData.identifier,
category: getCarrierCategory(assetData.carrier_name),
isRealName: assetData.real_name_status === 1
});
}
}
} catch (e) {
console.error('加载卡列表失败', e);
}
loading.value = false;
};
const getCarrierCategory = (carrierName) => {
if (!carrierName) return '126';
if (carrierName.includes('电信')) return '124';
if (carrierName.includes('联通')) return '125';
if (carrierName.includes('移动')) return '126';
return '126';
};
const toReal = async (card) => {
@@ -200,4 +232,16 @@
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>

View File

@@ -1,29 +1,45 @@
<template>
<view class="container">
<view class="card flex-col-g20">
<view class="flex-row-g20">
<label for="">手机号:</label>
<up-input placeholder="请输入绑定的手机号" border="surround" v-model="bind.phone" />
<view class="page">
<!-- 自定义导航栏 -->
<view v-if="!fromLogin" class="custom-navbar">
<view class="navbar-back" @tap="goBack">
<text class="back-text">🔙</text>
</view>
<view class="navbar-title">绑定手机号</view>
<view class="navbar-placeholder"></view>
</view>
<view class="flex-row-g20">
<label for="">验证码:</label>
<up-input placeholder="验证码" v-model="bind.code">
<template #suffix>
<up-button @tap="getCode" :text="codeText" type="success" :disabled="cooldown > 0"></up-button>
</template>
</up-input>
</view>
<view class="container" :style="{ paddingTop: fromLogin ? '0' : '0' }">
<view class="card flex-col-g20">
<view class="flex-row-g20">
<label for="">手机号:</label>
<up-input placeholder="请输入绑定的手机号" border="surround" v-model="bind.phone" />
</view>
<view class="btn">
<up-button type="primary" @tap="bindPhone">绑定手机号</up-button>
<view class="flex-row-g20">
<label for="">验证码:</label>
<up-input placeholder="验证码" v-model="bind.code">
<template #suffix>
<up-button @tap="getCode" :text="codeText" type="success" :disabled="cooldown > 0"></up-button>
</template>
</up-input>
</view>
<view class="btn">
<up-button type="primary" @tap="bindPhone">绑定手机号</up-button>
</view>
<!-- 如果从登录页面跳转过来显示退出按钮 -->
<view class="btn" v-if="fromLogin">
<up-button type="error" @tap="logout">退出</up-button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { authApi } from '@/api/index.js';
let bind = reactive({
@@ -33,7 +49,16 @@
let cooldown = ref(0);
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 getCode = async () => {
if (!bind.phone) {
@@ -77,13 +102,89 @@ let timer = null;
await authApi.bindPhone(bind.phone, bind.code);
uni.showToast({ title: '绑定成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
if (fromLogin.value) {
// 从登录页面过来的,绑定成功后跳转到首页
uni.redirectTo({ url: '/pages/index/index' });
} else {
// 从其他页面过来的,返回上一页
uni.navigateBack();
}
}, 1500);
} catch (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.navigateBack();
};
</script>
<style>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: var(--bg-secondary);
}
.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 {
font-size: 36rpx;
color: var(--text-primary);
}
.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>

View File

@@ -3,7 +3,7 @@
<view class="card">
<view class="form-item">
<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 class="form-item">
@@ -37,7 +37,7 @@
</template>
<script setup>
import { reactive, ref } from 'vue';
import { reactive, ref, onMounted } from 'vue';
import { authApi } from '@/api/index.js';
const form = reactive({
@@ -47,6 +47,17 @@
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 newCooldown = ref(0);
let oldCodeText = ref('获取验证码');

View File

@@ -8,34 +8,34 @@
<view v-else-if="exchangeData" class="card exchange-card">
<view class="exchange-header flex-row-sb">
<view class="exchange-no">{{ exchangeData.exchange_no }}</view>
<view class="tag-apple" :class="getStatusClass(exchangeData.status)">{{ exchangeData.status_text }}</view>
<view class="exchange-no">{{ exchangeData?.exchange_no }}</view>
<view class="tag-apple" :class="getStatusClass(exchangeData?.status)">{{ exchangeData?.status_text }}</view>
</view>
<view class="exchange-info">
<view class="info-row flex-row-sb">
<view class="info-label">换货原因</view>
<view class="info-value">{{ exchangeData.exchange_reason }}</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 class="info-value">{{ formatTime(exchangeData?.created_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-value">{{ exchangeData.recipient_name }}</view>
<view class="info-value">{{ exchangeData?.recipient_name }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.recipient_phone">
<view class="info-row flex-row-sb" v-if="exchangeData?.recipient_phone">
<view class="info-label">联系电话</view>
<view class="info-value">{{ exchangeData.recipient_phone }}</view>
<view class="info-value">{{ exchangeData?.recipient_phone }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.recipient_address">
<view class="info-row flex-row-sb" v-if="exchangeData?.recipient_address">
<view class="info-label">收货地址</view>
<view class="info-value address-value">{{ exchangeData.recipient_address }}</view>
<view class="info-value address-value">{{ exchangeData?.recipient_address }}</view>
</view>
</view>
<view class="exchange-actions" v-if="exchangeData.status === 1">
<view class="exchange-actions" v-if="exchangeData?.status === 1">
<button class="btn-apple btn-primary" @tap="openPopup">填写信息</button>
</view>
</view>
@@ -71,7 +71,7 @@
const userStore = useUserStore();
let exchangeData = reactive(null);
let exchangeData = ref(null);
let loading = ref(false);
let showPopup = ref(false);
let form = reactive({
@@ -100,11 +100,11 @@
loading.value = true;
try {
const data = await exchangeApi.getPending(userStore.state.identifier);
if (data) {
exchangeData.splice(0, exchangeData.length, data);
}
// 接口返回的是单个对象,不是数组
exchangeData.value = data || null;
} catch (e) {
console.error('加载换货记录失败', e);
exchangeData.value = null;
}
loading.value = false;
};
@@ -125,6 +125,14 @@
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
return;
}
// 手机号格式校验
const phoneReg = /^1[3-9]\d{9}$/;
if (!phoneReg.test(form.recipient_phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
if (!form.recipient_address) {
uni.showToast({ title: '请输入收货地址', icon: 'none' });
return;
@@ -132,7 +140,7 @@
try {
await exchangeApi.submitShippingInfo(
exchangeData.id,
exchangeData.value.id,
form.recipient_name,
form.recipient_phone,
form.recipient_address

View File

@@ -1,17 +1,18 @@
<template>
<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"
@authentication="enterDetail('authentication')" />
<VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
@openDatePicker="openDateRangePicker" />
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
@openDatePicker="openDateRangePicker" /> -->
<TrafficCard :deviceInfo="deviceInfo" :isDevice="userInfo.isDevice" />
<TrafficCard :identifier="currentCardNo" @packageLoaded="handlePackageLoaded" />
<WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" />
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi" @copy="copy" />
@@ -120,8 +121,13 @@
import WifiCard from '@/components/WifiCard.vue';
import FunctionCard from '@/components/FunctionCard.vue';
import FloatingButton from '@/components/FloatingButton.vue';
import { assetApi, deviceApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
assetApi,
deviceApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
const userStore = useUserStore();
@@ -137,29 +143,35 @@
});
let deviceInfo = reactive({
battery: 100,
battery: 0,
connect: 0,
statusStr: '正常',
expireDate: '',
currentIccid: '',
category: '',
phone: '',
network_status: 1,
status: 1,
expireDate: '-',
currentIccid: '-',
category: '-',
phone: '-',
flowSize: 0,
totalBytesCnt: 0,
ssidName: '',
ssidPwd: '',
rssi: '',
ssidName: '-',
ssidPwd: '-',
rssi: '-',
onlineStatus: '0',
connCnt: 0,
run_time: 0,
last_online_time: '',
lan_ip: '',
kf_url: '',
mchList: []
last_online_time: '-',
lan_ip: '-',
kf_url: '-',
mchList: [],
imei: '-',
max_clients: 1,
asset_type: 'device',
bound_phone: ''
});
let isRealName = ref(false);
let alreadyBindPhone = ref(false);
let boundPhone = ref('');
let realNameStatus = ref('');
let wifi_info = reactive({
ssid: '',
@@ -200,39 +212,96 @@
loading.value = true;
try {
const data = await assetApi.getInfo(identifier);
// 基本信息
userInfo.isDevice = data.asset_type === 'device';
currentCardNo.value = data.identifier;
deviceInfo.statusStr = data.status === 1 ? '正常' : '禁用';
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 || '';
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;
boundPhone.value = data.bound_phone || '';
alreadyBindPhone.value = !!data.bound_phone;
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
// 当前卡信息
if (data.cards && data.cards.length > 0) {
// 找到当前卡
const currentCard = data.cards.find(card => card.is_current) || data.cards[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 = [];
}
// 设备实时信息
if (data.device_realtime) {
const rt = data.device_realtime;
deviceInfo.onlineStatus = rt.online_status === 1 ? '1' : '0';
deviceInfo.battery = rt.battery_level || 100;
deviceInfo.ssidName = rt.ssid || '';
deviceInfo.ssidPwd = rt.wifi_password || '';
deviceInfo.lan_ip = rt.lan_ip || '';
deviceInfo.connCnt = rt.client_number || 0;
deviceInfo.battery = rt.battery_level ?? 0;
deviceInfo.ssidName = rt.ssid || '-';
deviceInfo.ssidPwd = rt.wifi_password || '-';
deviceInfo.lan_ip = rt.lan_ip || '-';
deviceInfo.connCnt = rt.client_number ?? 0;
deviceInfo.max_clients = rt.max_clients ?? 1;
deviceInfo.run_time = rt.run_time || 0;
deviceInfo.rssi = rt.rssi || '强';
deviceInfo.rssi = calculateSignalStrength(rt.rsrp, rt.rsrq, rt.rssi);
} 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.rssi = '-';
}
if (data.cards && data.cards.length > 0) {
deviceInfo.mchList = data.cards.map(card => ({
iccidMark: card.iccid,
category: card.slot_position
}));
}
// 到期时间由 TrafficCard 组件获取套餐信息时一并返回
} catch (e) {
console.error('加载资产信息失败', e);
}
loading.value = false;
};
// 计算信号强度
const calculateSignalStrength = (rsrp, rsrq, rssi) => {
// 简单的信号强度计算逻辑
if (!rsrp && !rsrq && !rssi) return '-';
const rsrpNum = parseInt(rsrp);
if (isNaN(rsrpNum)) return '-';
if (rsrpNum >= -80) return '强';
if (rsrpNum >= -95) return '中';
return '弱';
};
// 处理套餐加载完成事件(从 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 = () => {
wifi_info.ssid = deviceInfo.ssidName;
wifi_info.pwd = deviceInfo.ssidPwd;
@@ -249,26 +318,112 @@
);
deviceInfo.ssidName = wifi_info.ssid;
deviceInfo.ssidPwd = wifi_info.pwd;
uni.showToast({ title: '修改成功', icon: 'success' });
uni.showToast({
title: '修改成功',
icon: 'success'
});
} catch (e) {
console.error('修改WiFi失败', e);
}
showModifyWifi.value = false;
};
const connectKF = () => uni.showToast({
title: '正在跳转客服',
icon: 'none'
});
const enterBack = () => uni.showToast({
title: '正在跳转后台',
icon: 'none'
});
const connectKF = () => {
const kfUrl =
'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)}`
});
}
});
// #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 = () => {
if (!deviceInfo.lan_ip || deviceInfo.lan_ip === '-') {
uni.showToast({
title: '暂无后台地址',
icon: 'none'
});
return;
}
// 跳转到设备后台管理地址
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 () => {
try {
await deviceApi.reboot(userStore.state.identifier);
uni.showToast({ title: '重启指令已发送', icon: 'success' });
uni.showToast({
title: '重启指令已发送',
icon: 'success'
});
} catch (e) {
console.error('重启设备失败', e);
}
@@ -288,7 +443,10 @@
const YesRecover = async () => {
try {
await deviceApi.factoryReset(userStore.state.identifier);
uni.showToast({ title: '恢复出厂设置指令已发送', icon: 'success' });
uni.showToast({
title: '恢复出厂设置指令已发送',
icon: 'success'
});
} catch (e) {
console.error('恢复出厂设置失败', e);
}
@@ -425,8 +583,62 @@
showSmsCode.value = false;
};
const onSync = () => {
uni.showToast({ title: '同步成功', icon: 'success' });
const onSync = async () => {
const identifier = userStore.state.identifier;
if (!identifier) {
uni.showToast({
title: '请先登录',
icon: 'none'
});
return;
}
uni.showLoading({
title: '同步中...',
mask: true
});
try {
const data = await assetApi.refresh(identifier);
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) => {
@@ -460,8 +672,16 @@
});
break;
case 'change-phone':
if (!boundPhone.value) {
uni.showToast({
title: '请先去绑定手机号',
icon: 'none',
duration: 2000
});
return;
}
uni.navigateTo({
url: '/pages/change-phone/change-phone'
url: `/pages/change-phone/change-phone?oldPhone=${boundPhone.value}`
});
break;
case 'device-exchange':
@@ -500,16 +720,19 @@
<style scoped lang="scss">
.wifi-popup {
width: 600rpx;
padding: 30rpx;
padding: var(--space-6);
border-radius: var(--radius-xl);
background: var(--bg-surface);
.title {
font-size: 32rpx;
font-weight: 600;
font-size: 34rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-5);
}
}
.bottom-spacer {
height: 120rpx;
height: 140rpx;
}
</style>

View File

@@ -1,14 +1,63 @@
<template>
<view class="container">
<view class="card">
<view class="title-login">
登录
<view class="login-page">
<view class="overlay"></view>
<view class="animated-bg">
<view class="circle circle-1"></view>
<view class="circle circle-2"></view>
<view class="circle circle-3"></view>
<view class="circle circle-4"></view>
<view class="circle circle-5"></view>
<view class="shape shape-triangle"></view>
<view class="shape shape-square"></view>
<view class="shape shape-pentagon"></view>
<view class="shape shape-hexagon"></view>
</view>
<view class="content-wrapper">
<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="inputFocus = false"
@input="showError = false"
/>
</view>
<text v-if="showError" class="error">请输入标识</text>
</view>
<view class="input">
<up-input class="mt-30" v-model="identifier" placeholder="请输入SN/IMEI/虚拟号/ICCID/MSISDN"></up-input>
<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>
</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="button">
<up-button class="mt-30 btn-apple btn-primary" type="primary" :loading="loading" @click="login">立即登录</up-button>
</view>
</view>
</view>
@@ -18,14 +67,15 @@
import { ref, onMounted } from 'vue';
import { authApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { APP_ID } from '@/utils/env.js';
import { APP_ID, USE_WECHAT_AUTH } from '@/utils/env.js';
const userStore = useUserStore();
const identifier = ref('');
const loading = ref(false);
const isWechat = /micromessenger/i.test(navigator.userAgent);
const agreed = ref(true);
const inputFocus = ref(false);
const showError = ref(false);
const getCode = () => {
const params = new URLSearchParams(window.location.search);
@@ -37,28 +87,40 @@
};
const redirectToWxAuth = (assetToken) => {
const redirectUri = encodeURIComponent(window.location.href.split('?')[0]);
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`;
sessionStorage.setItem('assetToken', assetToken);
const redirectUri = encodeURIComponent('https://cmp-c.boss160.cn/');
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${APP_ID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`;
window.location.href = url;
};
const login = async () => {
const handleLogin = () => {
if (!identifier.value) {
uni.showToast({ title: '请输入SN/IMEI/虚拟号/ICCID/MSISDN', icon: 'none' });
showError.value = true;
return;
}
doLogin();
};
const doLogin = async () => {
loading.value = true;
try {
const verifyData = await authApi.verifyAsset(identifier.value);
userStore.setAssetToken(verifyData.asset_token);
userStore.setIdentifier(identifier.value);
// if (!isWechat) {
// uni.showToast({ title: '请在微信中打开', icon: 'none' });
// loading.value = false;
// return;
// }
if (!USE_WECHAT_AUTH) {
const loginData = await authApi.wechatLogin(verifyData.asset_token, 'mock_code');
userStore.setToken(loginData.token);
uni.setStorageSync('identifier', userStore.state.identifier);
// 判断是否需要绑定手机号
if (loginData.need_bind_phone) {
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
} else {
uni.redirectTo({ url: '/pages/index/index' });
}
return;
}
redirectToWxAuth(verifyData.asset_token);
} catch (e) {
@@ -68,8 +130,10 @@
};
const handleWechatCallback = async () => {
if (!USE_WECHAT_AUTH) return;
const code = getCode();
const assetToken = userStore.state.assetToken;
const assetToken = sessionStorage.getItem('assetToken');
if (!code || !assetToken) return;
@@ -79,9 +143,48 @@
try {
const loginData = await authApi.wechatLogin(assetToken, code);
userStore.setToken(loginData.token);
uni.setStorageSync('identifier', userStore.state.identifier);
uni.redirectTo({ url: '/pages/index/index' });
// H5 环境获取微信用户信息
// #ifdef H5
try {
// 通过 uni.getUserProfile 获取用户信息(需要用户点击授权)
// 注意:这个方法在微信公众号 H5 中不可用,需要通过后端接口获取
// 这里暂时保存默认信息,实际应该由后端返回用户信息
if (loginData.user_info) {
userStore.setUserInfo({
avatar: loginData.user_info.avatar || '',
nickname: loginData.user_info.nickname || ''
});
}
} catch (err) {
console.log('获取用户信息失败', err);
}
// #endif
// #ifdef MP-WEIXIN
// 小程序环境可以直接获取用户信息
try {
const { userInfo } = await uni.getUserProfile({
desc: '用于完善用户资料'
});
userStore.setUserInfo({
avatar: userInfo.avatarUrl || '',
nickname: userInfo.nickName || ''
});
} catch (err) {
console.log('用户取消授权');
}
// #endif
// 判断是否需要绑定手机号
if (loginData.need_bind_phone) {
// 需要绑定手机号,跳转到绑定页面
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
} else {
// 不需要绑定,直接进入首页
uni.redirectTo({ url: '/pages/index/index' });
}
} catch (e) {
console.error('微信登录失败', e);
loading.value = false;
@@ -94,23 +197,380 @@
</script>
<style lang="scss" scoped>
.container {
padding-top: 30vh;
.login-page {
position: relative;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #005cbf 0%, #007aff 50%, #4da6ff 100%);
min-height: 100vh;
}
.title-login {
color: var(--text-primary);
font-size: 24px;
text-align: center;
margin: 20px 0;
font-weight: bold;
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 50%);
z-index: 0;
}
.animated-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 0;
}
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(2px);
}
.circle-1 {
width: 300rpx;
height: 300rpx;
top: 10%;
left: -100rpx;
animation: float1 20s infinite ease-in-out;
}
.circle-2 {
width: 200rpx;
height: 200rpx;
top: 60%;
right: -50rpx;
animation: float2 15s infinite ease-in-out;
}
.circle-3 {
width: 150rpx;
height: 150rpx;
top: 30%;
right: 20%;
animation: float3 18s infinite ease-in-out;
}
.circle-4 {
width: 250rpx;
height: 250rpx;
bottom: 10%;
left: 10%;
animation: float4 22s infinite ease-in-out;
}
.circle-5 {
width: 180rpx;
height: 180rpx;
top: 50%;
left: 50%;
animation: float5 16s infinite ease-in-out;
}
@keyframes float1 {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.3;
}
.input {
margin: 70rpx 0 50rpx 0;
}
.button {
margin-bottom: 40rpx;
50% {
transform: translate(100rpx, -80rpx) scale(1.2);
opacity: 0.5;
}
}
</style>
@keyframes float2 {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.2;
}
50% {
transform: translate(-80rpx, 100rpx) scale(1.3);
opacity: 0.4;
}
}
@keyframes float3 {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.25;
}
50% {
transform: translate(-60rpx, -60rpx) scale(1.1);
opacity: 0.45;
}
}
@keyframes float4 {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.3;
}
50% {
transform: translate(120rpx, -100rpx) scale(1.15);
opacity: 0.5;
}
}
@keyframes float5 {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.2;
}
50% {
transform: translate(calc(-50% + 80rpx), calc(-50% + 80rpx)) scale(1.25);
opacity: 0.4;
}
}
.shape {
position: absolute;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(1px);
}
.shape-triangle {
width: 0;
height: 0;
background: transparent;
border-left: 80rpx solid transparent;
border-right: 80rpx solid transparent;
border-bottom: 140rpx solid rgba(255, 255, 255, 0.1);
top: 15%;
right: 15%;
animation: rotate1 25s infinite linear;
}
.shape-square {
width: 120rpx;
height: 120rpx;
top: 70%;
left: 5%;
transform: rotate(45deg);
animation: float-rotate 20s infinite ease-in-out;
}
.shape-pentagon {
width: 100rpx;
height: 100rpx;
clip-path: polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%);
top: 40%;
left: 80%;
animation: pulse 18s infinite ease-in-out;
}
.shape-hexagon {
width: 90rpx;
height: 90rpx;
clip-path: polygon(30% 0%, 70% 0%, 100% 50%, 70% 100%, 30% 100%, 0% 50%);
bottom: 25%;
right: 25%;
animation: float-spin 22s infinite ease-in-out;
}
@keyframes rotate1 {
0% {
transform: rotate(0deg);
opacity: 0.15;
}
50% {
opacity: 0.25;
}
100% {
transform: rotate(360deg);
opacity: 0.15;
}
}
@keyframes float-rotate {
0%, 100% {
transform: translate(0, 0) rotate(45deg);
opacity: 0.2;
}
50% {
transform: translate(80rpx, -60rpx) rotate(225deg);
opacity: 0.35;
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 0.2;
}
50% {
transform: scale(1.3);
opacity: 0.4;
}
}
@keyframes float-spin {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
opacity: 0.25;
}
50% {
transform: translate(-70rpx, 90rpx) rotate(180deg);
opacity: 0.4;
}
}
.content-wrapper {
position: relative;
padding: 60rpx 40rpx;
z-index: 1;
}
.header {
margin-bottom: 80rpx;
}
.main-title {
font-size: 56rpx;
font-weight: 700;
color: #FFFFFF;
line-height: 1.3;
margin-bottom: 24rpx;
letter-spacing: 0.5rpx;
text-shadow: 0 2rpx 8rpx rgba(64, 40, 128, 0.3);
}
.sub-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
line-height: 1.5;
letter-spacing: 0.5rpx;
text-shadow: 0 1rpx 4rpx rgba(64, 40, 128, 0.2);
}
.form-section {
margin-bottom: 60rpx;
}
.input-wrap {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 32rpx 40rpx;
transition: all 0.3s;
box-shadow: 0 4rpx 16rpx rgba(64, 40, 128, 0.1);
}
.input-wrap.focus {
background: rgba(255, 255, 255, 1);
box-shadow: 0 4rpx 20rpx rgba(64, 40, 128, 0.2);
}
.input {
flex: 1;
font-size: 32rpx;
color: #1A1A1A;
background: transparent;
}
.placeholder {
color: #C4C4C4;
}
.error {
font-size: 24rpx;
color: #EF4444;
margin-top: 16rpx;
padding-left: 8rpx;
}
.btn-section {
margin-bottom: 60rpx;
}
.btn-login {
width: 100%;
height: 108rpx;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 2rpx solid rgba(255, 255, 255, 0.4);
border-radius: 20rpx;
font-size: 36rpx;
font-weight: 600;
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.btn-login.disabled {
background: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
border: 2rpx solid rgba(255, 255, 255, 0.2);
box-shadow: none;
}
.btn-login:active:not(.disabled) {
opacity: 0.8;
}
.loading {
display: flex;
align-items: center;
gap: 8rpx;
}
.dot {
width: 10rpx;
height: 10rpx;
background: #FFFFFF;
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: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.link {
color: #FFFFFF;
font-weight: 500;
white-space: nowrap;
text-decoration: underline;
}
</style>

View File

@@ -2,7 +2,12 @@
<view class="container">
<view class="wallet-card">
<view class="balance-section">
<view class="balance-label">账户余额()</view>
<view class="balance-header">
<view class="balance-label">账户余额()</view>
<view class="recharge-btn-wrapper">
<button class="btn-recharge" @tap="showRechargeModal = true">充值</button>
</view>
</view>
<view class="balance-amount">{{ formatMoney(walletDetail.balance) }}</view>
</view>
<view class="wallet-footer">
@@ -13,7 +18,7 @@
<view class="footer-divider"></view>
<view class="footer-item">
<view class="footer-label">更新时间</view>
<view class="footer-value">{{ walletDetail.updated_at }}</view>
<view class="footer-value">{{ formatDateTime(walletDetail.updated_at) }}</view>
</view>
</view>
</view>
@@ -22,26 +27,28 @@
<view class="tab-header">
<view class="tab-indicator" :style="{ left: currentTab === 0 ? '8rpx' : '50%' }"></view>
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="onTabTap(0)">
<text>充值记录</text>
<text>充值订单</text>
</view>
<view class="tab-item" :class="{ active: currentTab === 1 }" @tap="onTabTap(1)">
<text>钱包流水</text>
</view>
</view>
<swiper class="tab-swiper" :current="currentTab" @change="onSwiperChange" :circular="false" :acceleration="true">
<swiper class="tab-swiper" :current="currentTab" @change="onSwiperChange" :circular="false"
:acceleration="true">
<swiper-item class="swiper-item-content">
<scroll-view scroll-y class="list-content">
<view v-if="rechargeList.length === 0 && !rechargeLoading" class="empty-state">
<view class="empty-icon">📋</view>
<view class="empty-title">暂无充值记录</view>
<view class="empty-title">暂无充值订单</view>
</view>
<view v-else>
<view class="card record-card" v-for="(item, index) in rechargeList" :key="index">
<view class="record-header flex-row-sb">
<view class="record-no">{{ item.recharge_no }}</view>
<view class="tag-apple" :class="getRechargeStatusClass(item.status)">{{ item.status_name }}</view>
<view class="record-no">{{ item.recharge_order_no || item.recharge_no }}</view>
<view class="tag-apple" :class="getRechargeStatusClass(item.status)">
{{ item.status_name }}</view>
</view>
<view class="record-info">
<view class="info-row flex-row-sb">
@@ -54,13 +61,17 @@
</view>
<view class="info-row flex-row-sb">
<view class="info-label">自动购包</view>
<view class="info-value">{{ item.auto_purchase_status === '1' ? '已开启' : '未开启' }}</view>
<view class="info-value">
{{ getAutoPurchaseStatusText(item.auto_purchase_status) }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">创建时间</view>
<view class="info-value">{{ item.created_at }}</view>
</view>
</view>
<view v-if="item.status === 0" class="record-actions">
<button class="btn-pay" @tap="handleRechargePayment(item)">立即支付</button>
</view>
</view>
<view class="load-more" v-if="rechargeList.length > 0">
@@ -83,7 +94,8 @@
<view class="card record-card" v-for="(item, index) in transactionList" :key="index">
<view class="record-header flex-row-sb">
<view class="record-type">{{ item.type_name }}</view>
<view class="record-amount" :class="item.amount >= 0 ? 'text-success' : 'text-danger'">
<view class="record-amount"
:class="item.amount >= 0 ? 'text-success' : 'text-danger'">
{{ item.amount >= 0 ? '+' : '' }}{{ formatMoney(item.amount) }}
</view>
</view>
@@ -116,14 +128,55 @@
</scroll-view>
</swiper-item>
</swiper>
<!-- 充值弹窗 -->
<view>
<up-popup :show="showRechargeModal" mode="center" @close="showRechargeModal=false">
<view class="recharge-popup">
<view class="popup-header">
<view class="popup-title">账户充值</view>
<view class="popup-close" @tap="showRechargeModal=false">×</view>
</view>
<view class="recharge-amounts">
<view class="amount-item" v-for="item in rechargeAmounts" :key="item.value"
:class="{ active: selectedAmount === item.value }" @tap="selectAmount(item.value)">
<text class="amount-value">¥{{ formatDisplayMoney(item.label) }}</text>
</view>
<view class="amount-item custom" :class="{ active: isCustomAmount }"
@tap="selectCustomAmount">
<text class="amount-value">自定义</text>
</view>
</view>
<view class="custom-input" v-if="isCustomAmount">
<view class="input-label">请输入充值金额</view>
<up-input v-model="customAmount" type="number" placeholder="最低0.01元" border="surround" />
</view>
<view class="popup-footer">
<button class="btn-apple btn-secondary" @tap="showRechargeModal=false">取消</button>
<button class="btn-apple btn-primary" @tap="confirmRecharge">确认充值</button>
</view>
</view>
</up-popup>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
ref,
reactive,
onMounted
} from 'vue';
import {
walletApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
const userStore = useUserStore();
@@ -142,6 +195,23 @@
return (amount / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const formatDisplayMoney = (amount) => {
if (!amount && amount !== 0) return '0';
// 直接格式化显示的数字(已经是元),添加千分号
return parseFloat(amount).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const formatDateTime = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
};
let walletDetail = reactive({
balance: 0,
frozen_balance: 0,
@@ -160,15 +230,236 @@
let transactionPage = ref(1);
const transactionPageSize = 5;
let showRechargeModal = ref(false);
let selectedAmount = ref(null);
let isCustomAmount = ref(false);
let customAmount = ref('');
const rechargeAmounts = [{
value: 1000,
label: '10'
},
{
value: 2000,
label: '20'
},
{
value: 5000,
label: '50'
},
{
value: 10000,
label: '100'
},
{
value: 20000,
label: '200'
},
{
value: 50000,
label: '500'
}
];
const selectAmount = (value) => {
selectedAmount.value = value;
isCustomAmount.value = false;
customAmount.value = '';
};
const selectCustomAmount = () => {
isCustomAmount.value = true;
selectedAmount.value = null;
};
const confirmRecharge = async () => {
let amount = 0;
if (isCustomAmount.value) {
// 自定义金额
const customAmountNum = parseFloat(customAmount.value);
if (!customAmountNum || customAmountNum < 0.01) {
uni.showToast({
title: '请输入正确的充值金额',
icon: 'none'
});
return;
}
amount = Math.round(customAmountNum * 100); // 转换为分
} else if (selectedAmount.value) {
// 预设金额
amount = selectedAmount.value;
} else {
uni.showToast({
title: '请选择充值金额',
icon: 'none'
});
return;
}
uni.showLoading({
title: '处理中...',
mask: true
});
try {
// 第一步:充值前校验
const checkData = await walletApi.rechargeCheck(userStore.state.identifier);
// 如果需要强制充值且金额小于强制充值金额
if (checkData.need_force_recharge && amount < checkData.force_recharge_amount) {
uni.hideLoading();
uni.showModal({
title: '提示',
content: `${checkData.message || '当前需充值最低金额'} ¥${(checkData.force_recharge_amount / 100).toFixed(2)}`,
showCancel: false
});
return;
}
// 检查金额范围
if (amount < checkData.min_amount || amount > checkData.max_amount) {
uni.hideLoading();
uni.showToast({
title: `充值金额范围:¥${(checkData.min_amount / 100).toFixed(2)} - ¥${(checkData.max_amount / 100).toFixed(2)}`,
icon: 'none',
duration: 2500
});
return;
}
// 第二步:创建充值订单
const rechargeData = await walletApi.recharge(
userStore.state.identifier,
amount,
'wechat'
);
uni.hideLoading();
showRechargeModal.value = false;
// 验证支付配置
if (!rechargeData.pay_config || !rechargeData.pay_config.package) {
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
return;
}
// 第三步:调起微信支付
// #ifdef H5
if (typeof WeixinJSBridge === 'undefined') {
uni.showToast({
title: '请在微信中打开',
icon: 'none'
});
return;
}
WeixinJSBridge.invoke('getBrandWCPayRequest', {
appId: rechargeData.pay_config.app_id,
timeStamp: rechargeData.pay_config.timestamp,
nonceStr: rechargeData.pay_config.nonce_str,
package: rechargeData.pay_config.package,
signType: rechargeData.pay_config.sign_type,
paySign: rechargeData.pay_config.pay_sign
}, function(res) {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
console.log('支付成功', res);
uni.showToast({
title: '充值成功',
icon: 'success'
});
setTimeout(() => {
loadWalletDetail();
rechargePage.value = 1;
rechargeNoMore.value = false;
loadRechargeList();
}, 1500);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
uni.showToast({
title: '已取消支付',
icon: 'none'
});
} else {
console.error('支付失败', res);
uni.showToast({
title: '支付失败',
icon: 'none'
});
}
});
// #endif
// #ifndef H5
uni.requestPayment({
timeStamp: rechargeData.pay_config.timestamp,
nonceStr: rechargeData.pay_config.nonce_str,
package: rechargeData.pay_config.package,
signType: rechargeData.pay_config.sign_type,
paySign: rechargeData.pay_config.pay_sign,
success: (res) => {
console.log('支付成功', res);
uni.showToast({
title: '充值成功',
icon: 'success'
});
setTimeout(() => {
loadWalletDetail();
rechargePage.value = 1;
rechargeNoMore.value = false;
loadRechargeList();
}, 1500);
},
fail: (err) => {
console.error('支付失败', err);
if (err.errMsg && (err.errMsg.includes('cancel') || err.errMsg.includes('用户取消'))) {
uni.showToast({
title: '已取消支付',
icon: 'none'
});
} else {
uni.showToast({
title: '支付失败',
icon: 'none'
});
}
}
});
// #endif
} catch (e) {
uni.hideLoading();
console.error('充值失败', e);
const errorMsg = e.msg || e.message || '充值失败,请稍后重试';
uni.showToast({
title: errorMsg,
icon: 'none'
});
}
};
const getRechargeStatusClass = (status) => {
const classMap = {
'0': 'tag-warning',
'1': 'tag-success',
'2': 'tag-danger'
0: 'tag-warning', // 待支付
1: 'tag-success', // 已支付
2: 'tag-danger', // 已关闭
3: 'tag-danger' // 已退款
};
return classMap[status] || '';
};
const getAutoPurchaseStatusText = (status) => {
const statusMap = {
'pending': '处理中',
'success': '已完成',
'failed': '失败',
'1': '已开启', // 兼容旧版
'0': '未开启' // 兼容旧版
};
return statusMap[status] || '未开启';
};
const loadWalletDetail = async () => {
try {
const data = await walletApi.getDetail(userStore.state.identifier);
@@ -185,16 +476,49 @@
rechargeLoading.value = true;
try {
const data = await walletApi.getRecharges(
// 使用新的充值订单接口
const data = await walletApi.getRechargeOrders(
userStore.state.identifier,
rechargePage.value,
rechargePageSize
);
const newData = (data.items || []).map(item => ({
...item,
status_name: item.status === 1 ? '已支付' : item.status === 0 ? '待支付' : '已关闭'
}));
const newData = (data.items || []).map(item => {
// 格式化时间
let formattedCreatedAt = item.created_at;
if (formattedCreatedAt && formattedCreatedAt.includes('T')) {
formattedCreatedAt = formattedCreatedAt.replace('T', ' ').slice(0, 19);
}
// 后端已返回 status_name优先使用
if (item.status_name) {
return {
...item,
created_at: formattedCreatedAt
};
}
// 兼容旧版本,手动映射
let statusName = '未知';
switch (item.status) {
case 0:
statusName = '待支付';
break;
case 1:
statusName = '已支付';
break;
case 2:
statusName = '已关闭';
break;
case 3:
statusName = '已退款';
break;
}
return {
...item,
status_name: statusName,
created_at: formattedCreatedAt
};
});
if (append) {
rechargeList.push(...newData);
@@ -213,6 +537,120 @@
rechargeLoading.value = false;
};
const handleRechargePayment = async (rechargeOrder) => {
uni.showLoading({
title: '处理中...',
mask: true
});
try {
// 使用订单金额重新调用充值接口获取支付配置
const rechargeData = await walletApi.recharge(
userStore.state.identifier,
rechargeOrder.amount,
'wechat'
);
uni.hideLoading();
// 验证支付配置
if (!rechargeData.pay_config || !rechargeData.pay_config.package) {
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
return;
}
// 调起微信支付
// #ifdef H5
if (typeof WeixinJSBridge === 'undefined') {
uni.showToast({
title: '请在微信中打开',
icon: 'none'
});
return;
}
WeixinJSBridge.invoke('getBrandWCPayRequest', {
appId: rechargeData.pay_config.app_id,
timeStamp: rechargeData.pay_config.timestamp,
nonceStr: rechargeData.pay_config.nonce_str,
package: rechargeData.pay_config.package,
signType: rechargeData.pay_config.sign_type,
paySign: rechargeData.pay_config.pay_sign
}, function(res) {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
uni.showToast({
title: '充值成功',
icon: 'success'
});
setTimeout(() => {
loadWalletDetail();
rechargePage.value = 1;
rechargeNoMore.value = false;
loadRechargeList();
}, 1500);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
uni.showToast({
title: '已取消支付',
icon: 'none'
});
} else {
uni.showToast({
title: '支付失败',
icon: 'none'
});
}
});
// #endif
// #ifndef H5
uni.requestPayment({
timeStamp: rechargeData.pay_config.timestamp,
nonceStr: rechargeData.pay_config.nonce_str,
package: rechargeData.pay_config.package,
signType: rechargeData.pay_config.sign_type,
paySign: rechargeData.pay_config.pay_sign,
success: (res) => {
uni.showToast({
title: '充值成功',
icon: 'success'
});
setTimeout(() => {
loadWalletDetail();
rechargePage.value = 1;
rechargeNoMore.value = false;
loadRechargeList();
}, 1500);
},
fail: (err) => {
if (err.errMsg && (err.errMsg.includes('cancel') || err.errMsg.includes('用户取消'))) {
uni.showToast({
title: '已取消支付',
icon: 'none'
});
} else {
uni.showToast({
title: '支付失败',
icon: 'none'
});
}
}
});
// #endif
} catch (e) {
uni.hideLoading();
console.error('获取支付配置失败', e);
const errorMsg = e.msg || e.message || '支付失败,请稍后重试';
uni.showToast({
title: errorMsg,
icon: 'none'
});
}
};
const loadTransactionList = async (append = false) => {
if (transactionLoading.value || transactionNoMore.value) return;
transactionLoading.value = true;
@@ -224,10 +662,19 @@
transactionPageSize
);
const newData = (data.items || []).map(item => ({
...item,
type_name: item.type === 'recharge' ? '充值' : item.type === 'purchase' ? '消费' : '其他'
}));
const newData = (data.items || []).map(item => {
// 格式化时间
let formattedCreatedAt = item.created_at;
if (formattedCreatedAt && formattedCreatedAt.includes('T')) {
formattedCreatedAt = formattedCreatedAt.replace('T', ' ').slice(0, 19);
}
return {
...item,
type_name: item.type === 'recharge' ? '充值' : item.type === 'purchase' ? '消费' : '其他',
created_at: formattedCreatedAt
};
});
if (append) {
transactionList.push(...newData);
@@ -284,8 +731,41 @@
.balance-section {
margin-bottom: 28rpx;
.balance-label { font-size: 24rpx; opacity: 0.75; margin-bottom: 8rpx; }
.balance-amount { font-size: 72rpx; font-weight: 700; letter-spacing: -2rpx; }
.balance-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
.balance-label {
font-size: 24rpx;
opacity: 0.75;
}
.recharge-btn-wrapper {
.btn-recharge {
background: #fff;
border: none;
color: var(--primary);
font-size: 26rpx;
padding: 12rpx 32rpx;
border-radius: 40rpx;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
&::after {
border: none;
}
}
}
}
.balance-amount {
font-size: 72rpx;
font-weight: 700;
letter-spacing: -2rpx;
}
}
.wallet-footer {
@@ -293,11 +773,27 @@
align-items: center;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
.footer-divider { width: 1rpx; height: 40rpx; background: rgba(255, 255, 255, 0.3); margin: 0 32rpx; }
.footer-divider {
width: 1rpx;
height: 40rpx;
background: rgba(255, 255, 255, 0.3);
margin: 0 32rpx;
}
.footer-item {
flex: 1;
.footer-label { font-size: 22rpx; opacity: 0.7; margin-bottom: 6rpx; }
.footer-value { font-size: 26rpx; font-weight: 600; }
.footer-label {
font-size: 22rpx;
opacity: 0.7;
margin-bottom: 6rpx;
}
.footer-value {
font-size: 26rpx;
font-weight: 600;
}
}
}
}
@@ -308,6 +804,7 @@
min-height: 0;
display: flex;
flex-direction: column;
.tab-header {
flex-shrink: 0;
display: flex;
@@ -315,6 +812,7 @@
background: #fff;
border-radius: 16rpx;
padding: 8rpx;
.tab-indicator {
position: absolute;
top: 8rpx;
@@ -325,6 +823,7 @@
transition: left 0.3s ease;
z-index: 0;
}
.tab-item {
flex: 1;
display: flex;
@@ -337,6 +836,7 @@
position: relative;
z-index: 1;
transition: color 0.3s;
&.active {
color: #fff;
font-weight: 600;
@@ -363,17 +863,68 @@
.record-card {
margin-bottom: 24rpx;
.record-header {
margin-bottom: 20rpx;
.record-no { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
.record-type { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
.record-amount { font-size: 32rpx; font-weight: 700; }
.record-no {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.record-type {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.record-amount {
font-size: 32rpx;
font-weight: 700;
}
}
.record-info {
.info-row {
padding: 10rpx 0;
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
.info-value { font-size: 24rpx; color: var(--text-primary); max-width: 380rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.info-label {
font-size: 24rpx;
color: var(--text-tertiary);
}
.info-value {
font-size: 24rpx;
color: var(--text-primary);
max-width: 380rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.record-actions {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid var(--border-light);
display: flex;
justify-content: flex-end;
.btn-pay {
background: var(--primary);
color: #fff;
border: none;
border-radius: 8rpx;
padding: 12rpx 32rpx;
font-size: 26rpx;
font-weight: 500;
width:100%;
&::after {
border: none;
}
}
}
}
@@ -384,8 +935,18 @@
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
.empty-icon { font-size: 100rpx; margin-bottom: 24rpx; opacity: 0.5; }
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--text-secondary); }
.empty-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
opacity: 0.5;
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-secondary);
}
}
.load-more {
@@ -394,4 +955,110 @@
color: var(--text-tertiary);
font-size: 24rpx;
}
.recharge-popup {
width: 600rpx;
max-width: 90vw;
background: var(--bg-primary);
border-radius: var(--radius-large);
overflow: hidden;
box-sizing: border-box;
.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;
}
}
.recharge-amounts {
padding: 30rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
.amount-item {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid var(--border-light);
border-radius: var(--radius-medium);
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: var(--primary);
background: rgba(0, 122, 255, 0.05);
.amount-value {
color: var(--primary);
font-weight: 600;
}
}
.amount-value {
font-size: 28rpx;
color: var(--text-primary);
}
}
}
.custom-input {
padding: 0 30rpx 30rpx;
.input-label {
font-size: 26rpx;
color: var(--text-secondary);
margin-bottom: 16rpx;
}
}
.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;
&.btn-secondary {
background: var(--gray-200);
color: var(--text-primary);
}
&.btn-primary {
background: var(--primary);
color: #fff;
}
}
}
}
</style>

View File

@@ -1,50 +1,83 @@
<template>
<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>
<view v-if="orderList.length === 0 && !loading" class="empty-state">
<view class="empty-icon">📋</view>
<view class="empty-title">暂无订单</view>
<view class="empty-desc">当前账号下暂无订单信息</view>
</view>
<view v-else class="card order-card" v-for="(item, index) in orderList" :key="index">
<view class="order-header flex-row-sb">
<view class="order-no">{{ item.order_no }}</view>
<view class="tag-apple" :class="getStatusClass(item.payment_status)">{{ item.payment_status_name }}</view>
</view>
<scroll-view v-else scroll-y class="order-scroll" @scrolltolower="loadMore">
<view class="order-card" v-for="(item, index) in orderList" :key="index">
<view class="card-header">
<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 class="order-info">
<view class="info-row flex-row-sb">
<view class="info-label">下单时间</view>
<view class="info-value">{{ item.created_at }}</view>
<view class="card-body">
<view class="info-grid">
<view class="info-item">
<view class="info-label">订单时间</view>
<view class="info-value">{{ item.created_at }}</view>
</view>
<view class="info-item">
<view class="info-label">订单金额</view>
<view class="info-value amount">¥{{ formatMoney(item.total_amount) }}</view>
</view>
</view>
<view class="divider"></view>
<view class="package-section">
<view class="section-label">套餐名称</view>
<view class="package-list">
<view class="package-item" v-for="(pkgName, pIndex) in item.package_names" :key="pIndex">
<text class="package-name">{{ pkgName }}</text>
</view>
</view>
</view>
<view v-if="item.payment_status === 1" class="card-footer">
<button class="btn-pay" @tap="handleOrderPayment(item)">立即支付</button>
</view>
</view>
</view>
<view class="divider"></view>
<view class="package-list">
<view class="package-item" v-for="(pkgName, pIndex) in item.package_names" :key="pIndex">
<view class="package-name">{{ pkgName }}</view>
</view>
<view class="load-more" v-if="orderList.length > 0">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore">没有更多了</text>
<text v-else>上拉加载更多</text>
</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 class="load-more" v-if="orderList.length > 0">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore">没有更多了</text>
<text v-else @tap="loadMore">点击加载更多</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { orderApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
ref,
reactive,
onMounted
} from 'vue';
import {
orderApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
const userStore = useUserStore();
@@ -53,6 +86,28 @@
let noMore = ref(false);
let page = ref(1);
const pageSize = 10;
let filterIndex = ref(0);
let filterOptions = [{
label: '全部',
value: null
},
{
label: '待支付',
value: 1
},
{
label: '已支付',
value: 2
},
{
label: '已取消',
value: 3
},
{
label: '已退款',
value: 4
}
];
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
@@ -61,27 +116,40 @@
const getStatusClass = (status) => {
const classMap = {
'0': 'tag-warning',
'1': 'tag-success',
'2': 'tag-danger'
1: 'tag-warning',
2: 'tag-success',
3: 'tag-secondary',
4: 'tag-info'
};
return classMap[status] || '';
};
const onFilterChange = (index) => {
filterIndex.value = index;
page.value = 1;
noMore.value = false;
loadOrderList();
};
const loadOrderList = async (append = false) => {
if (loading.value || noMore.value) return;
loading.value = true;
const paymentStatus = filterOptions[filterIndex.value].value;
try {
const data = await orderApi.getList(
userStore.state.identifier,
page.value,
pageSize
pageSize,
paymentStatus
);
const newData = (data.items || []).map(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) : ''
}));
@@ -108,6 +176,111 @@
}
};
const handleOrderPayment = async (order) => {
uni.showLoading({
title: '处理中...',
mask: true
});
try {
const payData = await orderApi.pay(order.order_id, 'wechat');
uni.hideLoading();
if (!payData.pay_config || !payData.pay_config.package) {
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
return;
}
// #ifdef H5
if (typeof WeixinJSBridge === 'undefined') {
uni.showToast({
title: '请在微信中打开',
icon: 'none'
});
return;
}
WeixinJSBridge.invoke('getBrandWCPayRequest', {
appId: payData.pay_config.app_id,
timeStamp: payData.pay_config.timestamp,
nonceStr: payData.pay_config.nonce_str,
package: payData.pay_config.package,
signType: payData.pay_config.sign_type,
paySign: payData.pay_config.pay_sign
}, function(res) {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
uni.showToast({
title: '支付成功',
icon: 'success'
});
setTimeout(() => {
page.value = 1;
noMore.value = false;
loadOrderList();
}, 1500);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
uni.showToast({
title: '已取消支付',
icon: 'none'
});
} else {
uni.showToast({
title: '支付失败',
icon: 'none'
});
}
});
// #endif
// #ifndef H5
uni.requestPayment({
timeStamp: payData.pay_config.timestamp,
nonceStr: payData.pay_config.nonce_str,
package: payData.pay_config.package,
signType: payData.pay_config.sign_type,
paySign: payData.pay_config.pay_sign,
success: () => {
uni.showToast({
title: '支付成功',
icon: 'success'
});
setTimeout(() => {
page.value = 1;
noMore.value = false;
loadOrderList();
}, 1500);
},
fail: (err) => {
if (err.errMsg && (err.errMsg.includes('cancel') || err.errMsg.includes('用户取消'))) {
uni.showToast({
title: '已取消支付',
icon: 'none'
});
} else {
uni.showToast({
title: '支付失败',
icon: 'none'
});
}
}
});
// #endif
} catch (e) {
uni.hideLoading();
console.error('支付失败', e);
const errorMsg = e.msg || e.message || '支付失败,请稍后重试';
uni.showToast({
title: errorMsg,
icon: 'none'
});
}
};
onMounted(() => {
loadOrderList();
});
@@ -115,62 +288,239 @@
<style lang="scss" scoped>
.container {
.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; }
}
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f6f7;
}
.order-card {
margin-bottom: var(--space-md);
.order-header {
margin-bottom: var(--space-md);
.order-no { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
.filter-tabs {
display: flex;
background: #fff;
position: sticky;
top: 0;
border-radius: 10rpx;
z-index: 10;
.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;
}
.order-info {
.info-row {
padding: var(--space-xs) 0;
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
.info-value { font-size: 24rpx; color: var(--text-primary); }
.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;
}
}
}
.order-scroll {
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 40rpx;
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.6;
}
.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 {
background: #fff;
border-radius: 20rpx;
margin-bottom: 24rpx;
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;
}
.header-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.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;
}
.order-id {
font-size: 24rpx;
color: #666;
font-family: monospace;
}
}
.card-body {
padding: 24rpx 28rpx;
.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;
}
}
}
}
.divider {
height: 1rpx;
background: var(--gray-200);
margin: var(--space-md) 0;
background: #f5f5f5;
margin: 24rpx 0;
}
.package-list {
.package-item {
padding: var(--space-sm) 0;
border-bottom: 1rpx solid var(--gray-100);
&:last-child { border-bottom: none; }
.package-name { font-size: 26rpx; color: var(--text-primary); }
.package-section {
.section-label {
font-size: 22rpx;
color: #999;
margin-bottom: 16rpx;
}
.package-list {
.package-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 0;
.package-name {
font-size: 26rpx;
color: #333;
}
}
}
}
.order-footer {
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1rpx solid var(--gray-200);
.total-label { font-size: 26rpx; color: var(--text-secondary); }
.total-amount { font-size: 32rpx; font-weight: 700; color: var(--danger); }
.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%;
&::after {
border: none;
}
}
}
}
}
.load-more {
text-align: center;
padding: var(--space-lg);
color: var(--text-tertiary);
font-size: 24rpx;
.status-badge {
padding: 8rpx 20rpx;
border-radius: 30rpx;
font-size: 22rpx;
font-weight: 500;
&.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 {
text-align: center;
padding: 32rpx;
color: #999;
font-size: 24rpx;
}
</style>

View File

@@ -9,33 +9,77 @@
<view class="card package-card" v-for="item in packageList" :key="item.package_id">
<view class="package-header flex-row-sb">
<view class="package-name">{{ item.package_name }}</view>
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
{{ item.is_addon ? '加油包' : '正式套餐' }}
<view class="header-right">
<view class="validity-tag">有效期{{ item.validity_days }}</view>
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
{{ item.is_addon ? '加油包' : '正式套餐' }}
</view>
</view>
</view>
<view class="package-price">¥{{ formatMoney(item.retail_price) }}</view>
<view class="package-data">{{ formatData(item.data_allowance, item.data_unit) }}</view>
<view class="package-desc">{{ item.description }}</view>
<view class="package-info">
<view class="info-item">流量: {{ formatData(item.data_allowance, item.data_unit) }}</view>
<view class="info-item">有效: {{ item.validity_days }}</view>
</view>
<view class="btn">
<up-button type="primary" @click="buyPackage(item)">立即订购</up-button>
<view class="package-footer flex-row-sb">
<view class="package-price">¥{{ formatMoney(item.retail_price) }}</view>
<view class="btn">
<up-button type="primary" @click="buyPackage(item)">立即订购</up-button>
</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>
<!-- 支付方式选择弹窗 -->
<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 === 'wechat' }" @tap="selectPaymentMethod('wechat')">
<view class="method-left">
<image class="method-icon" src="/static/wechat.png" mode="aspectFit"></image>
<text class="method-name">微信支付</text>
</view>
<view class="method-radio" :class="{ checked: paymentMethod === 'wechat' }"></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" @tap="confirmPay">确认支付</button>
</view>
</view>
</up-modal>
</up-popup>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { assetApi, orderApi } from '@/api/index.js';
import { assetApi, orderApi, walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
@@ -44,6 +88,8 @@
let currentPackage = ref(null);
let packageList = reactive([]);
let loading = ref(false);
let paymentMethod = ref('wechat');
let walletBalance = ref(0);
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
@@ -68,23 +114,230 @@
loading.value = false;
};
const loadWalletBalance = async () => {
try {
const data = await walletApi.getDetail(userStore.state.identifier);
walletBalance.value = data.balance || 0;
} catch (e) {
console.error('加载钱包余额失败', e);
}
};
const buyPackage = (item) => {
currentPackage.value = item;
paymentMethod.value = 'wechat';
showModal.value = true;
};
const confirmBuy = async () => {
const selectPaymentMethod = (method) => {
paymentMethod.value = method;
};
const confirmPay = async () => {
if (!currentPackage.value) return;
uni.showLoading({ title: '创建订单...', mask: true });
try {
await orderApi.create(userStore.state.identifier, [currentPackage.value.package_id]);
uni.showToast({ title: '购买成功', icon: 'success' });
// 第一步:创建订单
const orderResult = await orderApi.create(
userStore.state.identifier,
[currentPackage.value.package_id]
);
// 判断是否为强充订单
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 && (!orderResult.pay_config || !orderResult.pay_config.package)) {
// 待支付订单已存在,引导用户跳转到订单页面
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 (!orderResult.pay_config || !orderResult.pay_config.package) {
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 || [];
uni.hideLoading();
showModal.value = false;
// 提示用户需要强充
const packageNameStr = packageNames.length > 0 ? packageNames.join('、') : '套餐';
const message = `购买${packageNameStr}需要先充值 ¥${formatMoney(forceRechargeAmount)},充值成功后将自动购买套餐`;
uni.showModal({
title: '需要充值',
content: message,
confirmText: '去充值',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
// 调起微信支付
await handleWechatPay(orderResult.pay_config, true);
}
}
});
return;
}
// 检查是否有订单信息(非强充订单)
if (!orderResult || !orderResult.order) {
uni.hideLoading();
showModal.value = false;
uni.showToast({ title: '订单创建失败', icon: 'none' });
return;
}
const orderId = orderResult.order.order_id;
// 第二步:获取支付参数
uni.showLoading({ title: '准备支付...', mask: true });
const payResult = await orderApi.pay(orderId, paymentMethod.value);
uni.hideLoading();
showModal.value = false;
// 处理不同支付方式
if (paymentMethod.value === 'wechat') {
// 微信支付
if (!payResult.pay_config || !payResult.pay_config.package) {
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
return;
}
await handleWechatPay(payResult.pay_config);
} else if (paymentMethod.value === 'wallet') {
// 钱包支付成功
uni.showToast({ title: '支付成功', icon: 'success' });
setTimeout(() => {
loadWalletBalance();
}, 1500);
}
} catch (e) {
console.error('购买套餐失败', e);
uni.hideLoading();
showModal.value = false;
console.error('创建订单或支付失败', e);
// 特殊错误处理:订单正在创建中
if (e.code === 1008) {
uni.showModal({
title: '提示',
content: '订单正在创建中,是否跳转到我的订单页面进行支付?',
confirmText: '去支付',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/order-list/order-list' });
}
}
});
return;
}
// 其他错误提示
const errorMsg = e.msg || e.message || '操作失败,请稍后重试';
uni.showToast({ title: errorMsg, icon: 'none' });
}
showModal.value = false;
};
const handleWechatPay = async (payConfig, isForceRecharge = false) => {
// 验证参数
if (!payConfig.package || !payConfig.package.startsWith('prepay_id=')) {
uni.showToast({ title: '支付参数无效', icon: 'none' });
return;
}
// #ifdef H5
if (typeof WeixinJSBridge === 'undefined') {
uni.showToast({ title: '请在微信中打开', icon: 'none' });
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') {
const successMsg = isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功';
uni.showToast({ title: successMsg, icon: 'success', duration: 2000 });
setTimeout(() => {
loadWalletBalance();
}, 1500);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
uni.showToast({ title: '已取消支付', icon: 'none' });
} else {
uni.showToast({ title: '支付失败', icon: 'none' });
}
});
// #endif
// #ifndef H5
uni.requestPayment({
timeStamp: payConfig.timestamp,
nonceStr: payConfig.nonce_str,
package: payConfig.package,
signType: payConfig.sign_type,
paySign: payConfig.pay_sign,
success: (res) => {
const successMsg = isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功';
uni.showToast({ title: successMsg, icon: 'success', duration: 2000 });
setTimeout(() => {
loadWalletBalance();
}, 1500);
},
fail: (err) => {
if (err.errMsg && (err.errMsg.includes('cancel') || err.errMsg.includes('用户取消'))) {
uni.showToast({ title: '已取消支付', icon: 'none' });
} else {
uni.showToast({ title: '支付失败', icon: 'none' });
}
}
});
// #endif
};
onMounted(() => {
loadPackages();
loadWalletBalance();
});
</script>
@@ -107,25 +360,226 @@
.package-header {
margin-bottom: var(--space-sm);
.package-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
.header-right {
display: flex;
align-items: center;
gap: 12rpx;
.validity-tag {
font-size: 24rpx;
color: var(--text-secondary);
padding: 4rpx 12rpx;
background: var(--gray-100);
border-radius: 8rpx;
}
}
}
.package-price {
font-size: 40rpx;
font-weight: bold;
color: var(--warning);
.package-data {
font-size: 48rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: var(--space-sm);
letter-spacing: -1rpx;
}
.package-desc {
font-size: 26rpx;
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
.package-info {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-md);
.info-item { font-size: 24rpx; color: var(--text-tertiary); }
}
.btn { margin-top: var(--space-sm); }
.package-footer {
align-items: center;
.package-price {
font-size: 40rpx;
font-weight: 700;
color: var(--warning);
}
.btn {
flex-shrink: 0;
}
}
}
}
.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-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;
&.btn-secondary {
background: var(--gray-200);
color: var(--text-primary);
}
&.btn-primary {
background: var(--primary);
color: #fff;
}
}
}
}
</style>

View File

@@ -1,5 +1,11 @@
<template>
<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="flex-row-sb mt-30">
<view class="flex-row-g20">
@@ -14,9 +20,6 @@
</view>
</view>
<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">
<up-tag :type="item.real_name_status === 1 ? 'primary' : 'success'" size="mini">{{ item.real_name_status === 1 ? '已实名' : '未实名' }}</up-tag>
</view>
@@ -25,7 +28,10 @@
</view>
</view>
<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>
</view>
@@ -35,12 +41,13 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
import { deviceApi, assetApi } from '@/api/index.js';
import { assetApi, deviceApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
let mchList = reactive([]);
let loading = ref(false);
let switching = ref(false);
let opratorList = reactive([
@@ -55,17 +62,49 @@
};
const loadCards = async () => {
loading.value = true;
try {
const data = await assetApi.getInfo(userStore.state.identifier);
if (data.cards && data.cards.length > 0) {
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
...card,
category: card.carrier_name.includes('电信') ? '124' : card.carrier_name.includes('联通') ? '125' : '126'
})));
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) {
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
iccid: card.iccid,
carrier_name: card.carrier_name,
is_current: card.is_active,
real_name_status: card.real_name_status,
slot_position: card.slot_position,
category: getCarrierCategory(card.carrier_name)
})));
}
} else {
// 不是设备(单卡),直接使用 asset info 数据,只有一个卡
if (assetData.identifier) {
mchList.splice(0, mchList.length, {
iccid: assetData.identifier,
carrier_name: assetData.carrier_name,
is_current: true,
real_name_status: assetData.real_name_status,
slot_position: 1,
category: getCarrierCategory(assetData.carrier_name)
});
}
}
} catch (e) {
console.error('加载卡列表失败', e);
}
loading.value = false;
};
const getCarrierCategory = (carrierName) => {
if (!carrierName) return '126';
if (carrierName.includes('电信')) return '124';
if (carrierName.includes('联通')) return '125';
if (carrierName.includes('移动')) return '126';
return '126';
};
const switchOperator = async (item) => {
@@ -118,4 +157,16 @@
color: var(--text-inverse);
}
}
.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>

4757
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
static/data.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
static/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -5,7 +5,11 @@ const state = reactive({
assetToken: '',
identifier: uni.getStorageSync('identifier') || '',
isDevice: false,
realNameStatus: 0
realNameStatus: 0,
userInfo: uni.getStorageSync('userInfo') || {
avatar: '',
nickname: ''
}
});
export const useUserStore = () => {
@@ -31,11 +35,18 @@ export const useUserStore = () => {
state.realNameStatus = status;
};
const setUserInfo = (userInfo) => {
state.userInfo = userInfo;
uni.setStorageSync('userInfo', userInfo);
};
const clearUser = () => {
state.token = '';
state.assetToken = '';
state.userInfo = { avatar: '', nickname: '' };
uni.removeStorageSync('token');
uni.removeStorageSync('identifier');
uni.removeStorageSync('userInfo');
};
return {
@@ -45,6 +56,7 @@ export const useUserStore = () => {
setIdentifier,
setIsDevice,
setRealNameStatus,
setUserInfo,
clearUser
};
};

View File

@@ -74,3 +74,5 @@ $uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;

View File

@@ -1,5 +1,7 @@
export const BASE_URL = 'https://cmp-api.boss160.cn';
export const APP_TYPE = 'miniapp';
export const APP_TYPE = 'official_account';
export const APP_ID = 'wxea8c599fe100ce8a';
export const APP_ID = 'wx404dc822ce15c5f0';
export const USE_WECHAT_AUTH = true;