Compare commits

1 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
86 changed files with 3195 additions and 7354 deletions

View File

@@ -2,8 +2,13 @@
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(npm list:*)",
"Bash(find . -type f \\\\\\(-name *.openapi.* -o -name *-spec.yaml -o -name *-spec.yml \\\\\\))"
"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": []

View File

@@ -1,15 +0,0 @@
node_modules
.git
.gitignore
*.md
.vscode
.idea
dist
unpackage
.DS_Store
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env.local
.env.*.local

View File

@@ -1 +0,0 @@
VITE_BASE_URL=https://cmp-api.boss160.cn

View File

@@ -1 +0,0 @@
VITE_BASE_URL=https://cmp-api.xm-iot.cn

View File

@@ -21,9 +21,8 @@ jobs:
run: |
export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
export GIT_SSL_NO_VERIFY=1
git clone https://git.boss160.cn/luo/device-voice-h5 .
git clone https://git.boss160.cn/luo/device-voice-h5.git .
git checkout ${{ github.sha }}
- name: 设置镜像标签
id: tag

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.

View File

@@ -1,18 +0,0 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

578
App.vue
View File

@@ -5,86 +5,89 @@
</template>
<style lang="scss">
@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: #F8F8F8;
--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 {
@@ -93,36 +96,31 @@
page {
color: var(--text-primary);
background: var(--bg-secondary);
// min-height: 100vh;
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;
width: 100%;
box-sizing: border-box;
}
.pd-20{
padding: 20rpx;
}
.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 {
@@ -131,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);
@@ -235,7 +250,7 @@
}
&.tag-primary {
background: rgba(10, 132, 255, 0.12);
background: var(--primary-tint);
color: var(--primary);
}
@@ -250,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);
@@ -304,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

@@ -18,20 +18,9 @@ COPY package.json ./
# 安装所有依赖
RUN npm install --legacy-peer-deps
# 安装 git (用于获取 commit 信息)
RUN apk add --no-cache git
# 复制源代码
COPY . .
# 列出文件确认复制成功
RUN ls -la
# 创建 src 目录软链接 (兼容 uni-app 新版本)
RUN mkdir -p src && \
ln -sf /build/manifest.json /build/src/manifest.json && \
ln -sf /build/pages.json /build/src/pages.json
# 构建 H5 生产版本
RUN npm run build:h5
@@ -57,8 +46,6 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# 从构建阶段复制构建产物
COPY --from=builder /build/dist/build/h5 /usr/share/nginx/html
COPY MP_verify_yU2Z8mp831jh6QX7.txt /usr/share/nginx/html/MP_verify_yU2Z8mp831jh6QX7.txt
COPY MP_verify_0UoX8yClVgREjgPj.txt /usr/share/nginx/html/MP_verify_0UoX8yClVgREjgPj.txt
COPY MP_verify_4E1dVbOcZ9KzTzoc.txt /usr/share/nginx/html/MP_verify_4E1dVbOcZ9KzTzoc.txt
# 暴露端口
EXPOSE 80

View File

@@ -1 +0,0 @@
0UoX8yClVgREjgPj

View File

@@ -1 +0,0 @@
4E1dVbOcZ9KzTzoc

View File

@@ -4,5 +4,4 @@ export { deviceApi } from './modules/device.js';
export { exchangeApi } from './modules/exchange.js';
export { orderApi } from './modules/order.js';
export { realnameApi } from './modules/realname.js';
export { walletApi } from './modules/wallet.js';
export { wechatApi } from './modules/wechat.js';
export { walletApi } from './modules/wallet.js';

View File

@@ -21,26 +21,19 @@ export const orderApi = {
});
},
create(identifier, package_ids, payment_method) {
const data = { identifier, package_ids };
if (payment_method === 'alipay') {
data.payment_method = payment_method;
} else {
data.app_type = APP_TYPE;
}
create(identifier, package_ids) {
return request({
url: '/api/c/v1/orders/create',
method: 'POST',
data
data: { identifier, package_ids, app_type: APP_TYPE }
});
},
pay(order_id, payment_method, password) {
const data = { payment_method };
if (payment_method === 'wechat') {
data.app_type = APP_TYPE;
}
const data = {
payment_method,
app_type: APP_TYPE
};
if (password) {
data.password = password;
}
@@ -50,4 +43,4 @@ export const orderApi = {
data
});
}
};
};

View File

@@ -1,12 +1,11 @@
import request from '@/utils/request.js';
export const realnameApi = {
getLink(identifier, iccid, options = {}) {
getLink(identifier, iccid) {
return request({
url: '/api/c/v1/realname/link',
method: 'GET',
data: { identifier, iccid },
...options
data: { identifier, iccid }
});
}
};
};

View File

@@ -11,15 +11,10 @@ export const walletApi = {
},
recharge(identifier, amount, payment_method) {
const data = { identifier, amount, payment_method };
if (payment_method === 'wechat') {
data.app_type = APP_TYPE;
}
return request({
url: '/api/c/v1/wallet/recharge',
method: 'POST',
data
data: { identifier, amount, payment_method, app_type: APP_TYPE }
});
},
@@ -64,4 +59,4 @@ export const walletApi = {
data: { identifier, page, page_size, ...params }
});
}
};
};

View File

@@ -1,18 +0,0 @@
import request from '@/utils/request.js';
export const wechatApi = {
getAppId() {
return request({
url: '/api/c/v1/wechat/appid',
method: 'GET'
});
},
getJssdkConfig(url) {
return request({
url: '/api/c/v1/wechat/jssdk-config',
method: 'GET',
data: { url }
});
}
};

View File

@@ -1,107 +1,217 @@
<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">{{ deviceInfo.carrier_name || '-' }}</view>
</view>
<view class="device-info flex-col-g16">
<view class="info-row flex-row-sb">
<view class="info-label">
<view class="subtitle">当前卡号 {{ deviceInfo.currentIccid }}</view>
</view>
<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" v-if="isDevice">IMEI{{ deviceInfo.imei || '-' }}</view>
<view class="subtitle" v-else>手机号{{ deviceInfo.bound_phone || '-' }}</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">{{ deviceInfo.signal_quality || '暂无数据' }}</view>
</view>
<view class="metric-item flex-col-center">
<view class="caption mb-xs">设备电量</view>
<view class="metric-value">{{ deviceInfo.battery }} %</view>
</view>
<view class="metric-item flex-col-center">
<view class="caption mb-xs">连接数量</view>
<view class="metric-value">{{ deviceInfo.connCnt }}/{{ deviceInfo.max_clients }}</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>
<view v-if="shouldShowSignalReason(deviceInfo)" class="signal-reason">
<text class="signal-reason-label">怀疑原因</text>
<text class="signal-reason-text">{{ deviceInfo.signal_bad_reason }}</text>
<view 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-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 },
isDevice: { type: Boolean, default: true },
opratorList: { type: Array, default: () => [] }
});
defineEmits(['authentication']);
const shouldShowSignalReason = (deviceInfo) =>
['信号较弱', '信号很差', '暂无数据'].includes(deviceInfo?.signal_quality) &&
!!deviceInfo?.signal_bad_reason;
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);
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
&:last-child {
border-bottom: none;
}
.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);
}
.info-label {
min-width: 200rpx;
}
.operator-badge {
padding: var(--space-1) var(--space-3);
background: var(--primary-tint);
border-radius: var(--radius-full);
}
.info-values {
flex: 1;
justify-content: flex-end;
}
}
.operator-name {
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.device-metrics {
background: var(--gray-100);
border-radius: var(--radius-small);
padding: 20rpx;
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
.metric-item {
flex: 1;
}
}
.signal-reason {
margin-top: 16rpx;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
font-size: 24rpx;
line-height: 1.5;
text-align: center;
color: var(--text-secondary);
}
.signal-reason-label {
color: var(--text-primary);
& + .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,111 +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" @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('sync')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/data.png" mode="aspectFit" alt="运营数据同步"></image>
</view>
<view class="function-name">运营数据同步</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'package-order')"
role="button" tabindex="0">
<view class="function-icon">
<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" @tap="$emit('enter', 'asset-package')"
role="button" tabindex="0">
<view class="function-icon">
<image src="/static/asset-package-history.png" mode="aspectFit" alt="资产套餐历史"></image>
</view>
<view class="function-name">资产套餐历史</view>
</view>
<view class="function-item interactive card-interactive" @tap="handleBindPhone" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
</view>
<view :class="['function-name', alreadyBindPhone ? 'text-primary' : 'text-danger']">
{{ alreadyBindPhone ? '已绑定' : '未绑定' }}
</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'change-phone')" role="button"
tabindex="0">
<view class="function-icon">
<image src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
</view>
<view class="function-name">更换手机号</view>
</view>
<view class="function-item interactive card-interactive"
@tap="$emit('enter', 'device-exchange')" role="button" tabindex="0">
<view class="function-icon">
<image src="/static/change.png" mode="aspectFit" alt="换货"></image>
</view>
<view class="function-name">换货</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'wallet')" role="button"
tabindex="0">
<view class="function-icon">
<image src="/static/wallet.png" mode="aspectFit" alt="钱包"></image>
</view>
<view class="function-name">{{ walletText }}</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'restart')"
role="button" tabindex="0">
<view class="function-icon">
<image src="/static/restart.png" mode="aspectFit" alt="重启设备"></image>
</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" @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>
@@ -128,78 +39,100 @@
isDevice: {
type: Boolean,
default: true
},
walletBalance: {
type: [Number, String],
default: 0
}
});
const emit = defineEmits(['enter', 'sync']);
const formatWalletBalance = (balance) => {
const amount = Number(balance);
if (!Number.isFinite(amount) || amount === 0) return '0';
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 (amount / 100).toFixed(2).replace(/\.?0+$/, '');
};
return list.filter(item => !item.deviceOnly || props.isDevice);
});
const walletText = computed(() => `钱包 (${formatWalletBalance(props.walletBalance)})`);
const displayFunctions = computed(() => functions.value);
const handleBindPhone = () => {
if (props.alreadyBindPhone) {
return;
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);
}
emit('enter', 'bind');
};
</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;
.function-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
padding-top: var(--space-5);
}
</style>
.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,36 +1,40 @@
<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">
<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">
<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">{{ remainValue }}</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>
@@ -39,23 +43,12 @@
</template>
<script setup>
import {
ref,
computed,
watch
} from 'vue';
import {
assetApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
import { ref, computed, watch } from 'vue';
import { assetApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const props = defineProps({
identifier: {
type: String,
default: ''
}
identifier: { type: String, default: '' }
});
const emit = defineEmits(['packageLoaded']);
@@ -67,15 +60,10 @@
const totalMB = ref(0);
const remainMB = ref(0);
const toMB = (value) => Number(value) || 0;
// 格式化流量数据MB 转 GB
const formatTraffic = (mb) => {
if (!mb || mb === 0) {
return {
value: '0.00',
unit: 'MB'
};
return { value: '0.00', unit: 'MB' };
}
if (mb >= 1024) {
@@ -105,41 +93,41 @@
// 使用百分比
const usedFlowPercent = computed(() => {
if (totalMB.value > 0) {
if (totalMB.value && usedMB.value) {
const percent = (usedMB.value / totalMB.value * 100);
return Math.min(percent, 100).toFixed(2);
return Math.min(percent, 100).toFixed(1);
}
return '0.00';
return '0.0';
});
const getTrafficMetrics = (data) => {
const enableVirtualData = !!data.enable_virtual_data;
const used = enableVirtualData ? toMB(data.virtual_used_mb) : toMB(data.real_used_mb);
const total = toMB(data.real_total_mb);
const remain = Math.max(total - used, 0);
return {
used,
total,
remain
};
};
// 加载套餐流量数据
const loadPackageData = async () => {
const identifier = props.identifier || userStore.state.identifier;
if (!identifier) return;
try {
const data = await assetApi.getInfo(identifier);
const data = await assetApi.getPackageHistory(identifier, 1, 10, { status: 1 });
if (data.current_package_usage_id) {
const metrics = getTrafficMetrics(data);
usedMB.value = metrics.used;
totalMB.value = metrics.total;
remainMB.value = metrics.remain;
emit('packageLoaded', data);
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;
@@ -155,34 +143,110 @@
if (newVal) {
loadPackageData();
}
}, {
immediate: true
});
}, { immediate: true });
</script>
<style scoped lang="scss">
.traffic-card {
.usage-percent {
display: flex;
align-items: baseline;
gap: 4rpx;
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.title {
font-size: 35rpx;
font-weight: 700;
margin-right: 5rpx;
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-5);
}
.traffic-stats {
background: var(--gray-100);
border-radius: var(--radius-small);
padding: 20rpx;
margin-top: 16rpx;
.usage-indicator {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.stat-item {
flex: 1;
}
.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,27 +1,25 @@
<template>
<view class="card user-info-card interactive">
<view class="flex-row-g20">
<view class="user-details flex-col-g8">
<view class="flex-row-g20">
<view class="title">{{ currentCardNo || '-' }}</view>
</view>
<view class="iccid-row caption">
<text>ICCID: {{ deviceInfo.iccid || '-' }}</text>
<button
v-if="deviceInfo.iccid && deviceInfo.iccid !== '-'"
class="copy-btn"
@tap.stop="$emit('copy', deviceInfo.iccid)"
>
复制
</button>
</view>
<view class="caption">套餐名称{{ deviceInfo.packageName || '-' }}</view>
<view class="caption">套餐到期时间{{ deviceInfo.expireDate || '-' }}</view>
<view class="user-info-card">
<view class="card-main">
<view class="user-avatar">
<image :src="avatarUrl" mode="aspectFill" alt="用户头像" />
</view>
<view v-if="isDevice" class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
{{ onlineStatus }}
<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>
<view v-else class="tag-apple" :class="networkStatus == 1 ? 'tag-success' : 'tag-warning'">
</view>
<view class="card-footer" v-if="!isDevice">
<view class="network-badge" :class="networkStatus == 1 ? 'network-ok' : 'network-stop'">
{{ networkStatus == 1 ? '正常' : '停机' }}
</view>
</view>
@@ -29,7 +27,10 @@
</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: '离线' },
@@ -37,39 +38,129 @@
isDevice: { type: Boolean, default: true }
});
defineEmits(['copy']);
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);
background: var(--bg-surface);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-md);
}
.user-details {
flex: 1;
}
.card-main {
display: flex;
align-items: center;
gap: var(--space-4);
}
.iccid-row {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.user-avatar {
width: 96rpx;
height: 96rpx;
border-radius: var(--radius-lg);
overflow: hidden;
border: 4rpx solid var(--gray-100);
flex-shrink: 0;
.copy-btn {
margin: 0;
padding: 0 12rpx;
min-height: 36rpx;
line-height: 36rpx;
border: none;
border-radius: 6rpx;
background: rgba(10, 132, 255, 0.12);
color: var(--primary);
font-size: 22rpx;
font-weight: 600;
}
.copy-btn::after {
border: none;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>
.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,17 +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;
}
}
.btn-group{
padding-right: var(--space-md);
}
</style>
</style>

View File

@@ -18,8 +18,6 @@ server {
}
location / {
alias /usr/share/nginx/html/;
index index.html;
try_files $uri $uri/ /index.html;
}

View File

@@ -1,4 +1,4 @@
# BaseUrl: https://cmp-api.xm-iot.cn
# BaseUrl: https://cmp-api.boss160.cn
错误响应示例:
{
@@ -328,13 +328,6 @@ url: /api/c/v1/asset/info
URL
GET /api/c/v1/asset/info
更新说明2026-05-08
* 本月流量展示以该接口返回字段为准
* 总流量:`real_total_mb`
* 已使用:`enable_virtual_data=true` 时取 `virtual_used_mb`,否则取 `real_used_mb`
* 剩余:`real_total_mb - 已使用`
* 进度条:`已使用 / real_total_mb`
Query 参数:
identifierstring必填- 资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
@@ -381,11 +374,6 @@ data
URL
GET /api/c/v1/asset/package-history
更新说明2026-05-08
* 涉及“本月流量”展示时,以 `/api/c/v1/asset/info` 的规则为准
* 旧说明中“启用虚流量时总流量/剩余切换为虚流量”的规则已废弃
* 当前规则:总流量固定取 `real_total_mb`,已使用按 `enable_virtual_data``virtual_used_mb` / `real_used_mb` 间切换,剩余和进度条按该结果计算
Query 参数:
- identifierstring必填- 资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
- pageinteger必填- 页码

View File

@@ -1,4 +0,0 @@
我的钱包里面的账户充值里的确认充值按钮、我的钱包下面充值订单里的立即支付、套餐列表选择支付方式里的确认支付、我的订单里面的立即支付,都需要加上防抖。
OpenSpec 变更提案位置:
- `openspec/changes/add-payment-submit-guard/`

View File

@@ -1,187 +0,0 @@
# uniapp H5 调用微信扫一扫配置文档
## 一、微信 JSSDK 加载
**方式:通过 CDN 加载微信官方 JSSDK**
`index.html``<head>` 中添加:
```html
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
```
> **注意:** 不要使用 `import wx from 'jweixin-module'` 或 npm 包方式导入uni-app 会错误解析导致构建失败。
---
## 二、wxsdk.js 封装
创建 `utils/wxsdk.js`
```javascript
// utils/wxsdk.js
import { wechatApi } from '@/api/index.js'
// 注意:是 jWeixin不是 wx
// window.wx 会被 uni-app 框架占用为 Vue 模块
const wx = window.jWeixin
/**
* 初始化微信 JS-SDK
*/
export async function initWxConfig() {
const url = window.location.href.split('#')[0]
try {
const data = await wechatApi.getJssdkConfig(url)
const { app_id, timestamp, nonce_str, signature } = data
return new Promise((resolve, reject) => {
wx.config({
debug: true,
appId: app_id,
timestamp: timestamp,
nonceStr: nonce_str,
signature: signature,
jsApiList: ['scanQRCode']
})
wx.ready(() => {
console.log('微信 SDK 就绪')
resolve()
})
wx.error((err) => {
reject(err)
})
})
} catch (e) {
throw e
}
}
/**
* 调起微信扫一扫
* @returns {Promise<string>} 扫码结果字符串
*/
export function wxScan() {
return new Promise((resolve, reject) => {
wx.scanQRCode({
needResult: 1,
scanType: ['qrCode', 'barCode'],
success(res) {
resolve(res.resultStr)
},
fail(err) {
reject(new Error(err?.errMsg || '扫码失败'))
}
})
})
}
/**
* 检查是否在微信环境中
*/
export function isInWechat() {
return /micromessenger/i.test(navigator.userAgent)
}
```
---
## 三、后端接口要求
后端需提供 `/api/c/v1/wechat/jssdk-config` 接口,返回格式:
```json
{
"app_id": "wx1234567890",
"timestamp": 1234567890,
"nonce_str": "randomstring",
"signature": "xxxxx"
}
```
> **注意:** `nonce_str` 必须与生成 signature 时使用的一致
---
## 四、前端调用示例
```javascript
import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js'
async function handleScan() {
if (!isInWechat()) {
uni.showToast({ title: '请在微信环境中使用', icon: 'none' })
return
}
try {
// 1. 初始化微信 SDK 配置
await initWxConfig()
// 2. 调起扫一扫
const result = await wxScan()
console.log('扫码结果:', result)
// 在此处理扫码结果
} catch (err) {
console.error('扫码错误:', err)
uni.showToast({ title: err.message || JSON.stringify(err), icon: 'none' })
}
}
```
---
## 五、注意事项
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| `window.wx` 输出 `Module {$emit...}` | `window.wx` 被 uni-app 框架占用为 Vue 事件模块 | 使用 `window.jWeixin` |
| `jweixin-module` 打包报 `"default" is not exported` | uni 构建系统错误解析无 default export 的 UMD 模块 | 使用 CDN 加载,不走 npm |
| `wx.scanQRCode is not a function` | 微信 SDK 未正确加载 | 确认 `window.jWeixin` 有值 |
| 扫码提示 `{}` | `catch` 捕获空错误对象 | 需将错误信息转换为 `Error` 对象返回 |
---
## 六、调试建议
在微信开发者工具中打开控制台,检查以下变量:
```javascript
// 错误情况:被 uni-app 框架占用为 Vue 模块
window.wx
// 输出Module {$emit: ƒ, $off: ƒ, $on: ƒ, $once: ƒ, ...}
// 正确情况:微信 JSSDK
window.jWeixin
// 输出:{config: ƒ, ready: ƒ, error: ƒ, checkJsApi: ƒ, ...}
```
---
## 七、完整文件路径
```
project/
├── index.html # 引入微信 JSSDK CDN
├── utils/
│ └── wxsdk.js # 微信扫一扫封装
└── api/
└── modules/
└── wechat.js # 后端接口封装
```
`api/modules/wechat.js` 示例:
```javascript
import request from '@/utils/request.js'
export const wechatApi = {
getJssdkConfig(url) {
return request({
url: '/api/c/v1/wechat/jssdk-config',
method: 'GET',
data: { url }
})
}
}
```

View File

@@ -1,436 +0,0 @@
# 换货流程升级接口变更说明
## 一、数据库字段新增
### 表:`tb_exchange_order`
新增以下字段,所有换货单相关接口响应均会返回:
| 字段 | 类型 | 说明 |
| ------------ | ------------- | ---------------------------------------------------------- |
| flow_type | string | 流程类型:`shipping`(物流换货)或 `direct`(直接换货),历史数据为空时视为 `shipping` |
| shipped_at | string | null | 发货时间,仅 `shipping` 流程发货后有值 |
| completed_at | string | null | 换货完成时间,换货完成后有值 |
---
# 二、后台接口变更
## 1. 创建换货单
### POST /api/admin/exchanges
### 新增请求参数
```json
{
"old_asset_type": "iot_card",
"old_identifier": "xxx",
"exchange_reason": "设备损坏",
"flow_type": "shipping",
"new_identifier": "yyy",
"migrate_data": false,
"remark": null
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
| -------------- | ------------- | ---------- | ---------------------------------------- |
| flow_type | string | 否 | 流程类型:`shipping` / `direct`,默认 `shipping` |
| new_identifier | string | direct 时必填 | 新资产标识符 |
| migrate_data | boolean | 否 | 是否迁移数据,默认 false |
| remark | string | null | 否 | 备注 |
### 不同流程行为差异
| flow_type | 创建后状态(status) | completed_at |
| --------- | ------------- | ------------ |
| shipping | 1待填写信息 | null |
| direct | 4已完成 | 当前时间 |
### 说明
* shipping 为原有流程
* direct 创建后直接完成
* direct 不需要填写地址
* direct 不需要发货
* direct 不需要确认完成
* 非法 flow_type 返回参数错误
---
## 2. 换货单列表
### GET /api/admin/exchanges
### 新增查询参数
| 参数 | 类型 | 说明 |
| ---------- | ------ | ------------------------ |
| flow_type | string | 按流程类型筛选shipping/direct |
| identifier | string | 资产标识符模糊查询(同时匹配旧资产、新资产) |
### 响应新增字段
```json
{
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null
}
```
---
## 3. 换货单详情
### GET /api/admin/exchanges/:id
### 响应新增字段
```json
{
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null
}
```
### 说明
对于 direct 类型:
* 收货信息为空
* 物流信息为空
属于正常情况,前端需根据 flow_type 控制相关区块显示。
---
## 4. 发货
### POST /api/admin/exchanges/:id/ship
### 新增限制
仅支持:
```text
flow_type = shipping
```
### 不允许情况
```text
flow_type = direct
```
返回:
```json
{
"code": 400,
"message": "流程类型不支持该操作"
}
```
### 前端处理
```javascript
exchange.flow_type === 'shipping'
```
---
## 5. 确认完成
### POST /api/admin/exchanges/:id/complete
### 新增限制
仅支持:
```text
flow_type = shipping
status = 3
```
### 不允许情况
```text
flow_type = direct
```
返回错误。
---
## 6. 取消换货
### POST /api/admin/exchanges/:id/cancel
### 新增限制
允许取消:
```text
flow_type = shipping
status IN (1, 2)
```
### 不允许取消
#### direct 单据
```text
flow_type = direct
```
#### 已发货单据
```text
flow_type = shipping
status = 3
```
返回错误。
---
# 三、客户端接口变更
## 1. 查询进行中的换货单
### GET /api/c/v1/exchange/pending
### 变更后返回规则
仅返回:
```text
flow_type = shipping
status IN (1, 2, 3)
```
### 不返回
```text
flow_type = direct
```
客户端无需特殊处理。
---
## 2. 填写收货地址
### POST /api/c/v1/exchange/:id/shipping-info
### 新增限制
仅支持:
```text
flow_type = shipping
status = 1
```
### 不允许
```text
flow_type = direct
```
返回错误。
---
# 四、前端 UI 改造建议
## 换货单详情页
### 新增展示字段
* 流程类型flow_type_name
* 完成时间completed_at
* 发货时间shipped_at
### 按流程类型控制按钮
#### direct
不显示:
* 发货
* 确认完成
* 取消
#### shipping + status = 1
显示:
* 取消
#### shipping + status = 2
显示:
* 发货
* 取消
#### shipping + status = 3
显示:
* 确认完成
### 信息区块控制
#### direct
隐藏:
* 收货信息
* 物流信息
或者显示:
```text
不适用
```
---
## 创建换货单弹窗
### 新增字段
流程类型:
* 物流换货shipping
* 直接换货direct
### 选择 direct 时
显示:
* 新资产标识符(必填)
* 是否迁移数据(开关)
对应参数:
```json
{
"new_identifier": "xxx",
"migrate_data": true
}
```
### 选择 shipping 时
隐藏:
* 新资产标识符
* 是否迁移数据
---
## 换货单列表页
### 新增表格列
```text
流程类型
```
显示:
```text
物流换货
直接换货
```
### 新增筛选项
```text
流程类型
```
下拉选项:
```text
全部
物流换货shipping
直接换货direct
```
---
# 五、流程状态说明
## shipping物流换货
```text
创建
待填写信息1
待发货2
已发货3
已完成4
```
## direct直接换货
```text
创建
已完成4
```
---
# 六、状态与操作矩阵
| flow_type | status | 发货 | 确认完成 | 取消 |
| --------- | ------ | -- | ---- | -- |
| shipping | 1 | ❌ | ❌ | ✅ |
| shipping | 2 | ✅ | ❌ | ✅ |
| shipping | 3 | ❌ | ✅ | ❌ |
| shipping | 4 | ❌ | ❌ | ❌ |
| direct | 4 | ❌ | ❌ | ❌ |
---
# 七、枚举定义
## flow_type
```json
{
"shipping": "物流换货",
"direct": "直接换货"
}
```
## flow_type_name 示例
```json
{
"flow_type": "shipping",
"flow_type_name": "物流换货"
}
```
```json
{
"flow_type": "direct",
"flow_type_name": "直接换货"
}
```

View File

@@ -1,307 +0,0 @@
# 支付宝支付链接前端对接说明
## 1. 对接范围
本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK后端会返回已签名的支付宝 WAP 支付链接。
覆盖场景:
1. 套餐订单支付
2. 钱包充值
3. 强制充值购包场景
微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`
具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。
## 2. 支付方式取值
前端统一使用 `payment_method` 区分支付方式:
| 值 | 含义 | 前端处理 |
| --- | --- | --- |
| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 |
| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 |
| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 |
注意事项:
- `app_type` 只在微信支付时需要。
- 支付宝支付时不需要传 `app_type`
- 支付宝支付返回的是支付链接,不是 JSAPI 参数。
## 3. 前端需要关注的响应字段
当选择支付宝支付时,接口响应中会返回 `payment_link`
```json
{
"payment_link": {
"payment_no": "支付单号",
"qr_link": "支付宝支付链接,可用于生成二维码",
"copy_link": "支付宝支付链接,可复制或直接打开",
"pay_expire_at": "支付链接过期时间RFC3339 格式"
}
}
```
字段说明:
| 字段 | 用途 |
| --- | --- |
| `payment_no` | 支付单号,问题排查 / 客服查询使用 |
| `qr_link` | 前端可用该链接生成二维码 |
| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 |
| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 |
## 4. 推荐交互流程
### 4.1 支付宝支付流程
前端拿到 `payment_link` 后:
1. H5 / 浏览器场景:直接跳转 `copy_link`
支付成功后:
- 不要只依赖支付宝返回页判断支付成功。
- 最终支付状态以服务端订单 / 充值记录状态为准。
- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。
## 5. 各业务场景说明
### 5.1 套餐订单支付
普通套餐下单后,如果需要支付,前端调用订单支付接口,并传:
```json
{
"payment_method": "alipay"
}
```
支付宝支付响应示例:
```json
{
"payment_method": "alipay",
"payment_link": {
"payment_no": "...",
"qr_link": "...",
"copy_link": "...",
"pay_expire_at": "..."
}
}
```
前端根据 `payment_link` 发起支付宝支付。
### 5.2 钱包充值
创建钱包充值单时传:
```json
{
"payment_method": "alipay"
}
```
支付宝场景返回 `payment_link`;微信场景返回 `pay_config`
### 5.3 强制充值购包
创建订单时,如果后端判断需要强制充值,前端可指定:
```json
{
"payment_method": "alipay"
}
```
如果是支付宝强充,响应中会返回:
```json
{
"order_type": "recharge",
"recharge": {},
"payment_link": {}
}
```
前端按支付宝支付链接流程处理。
## 6. 前端展示建议
支付宝支付页面建议展示:
- 支付金额
- 支付宝二维码或“去支付宝支付”按钮
- 支付链接过期倒计时
- “我已完成支付”按钮
- “重新获取支付链接”按钮
点击“我已完成支付”时,不直接判定成功,应查询服务端状态。
## 7. 注意事项
1. 金额单位仍然是 **分**
2. 支付宝支付不需要 `app_type`
3. `pay_config` 只用于微信支付。
4. `payment_link` 只用于支付宝支付。
5. 支付状态以服务端为准,不以前端跳转结果为准。
6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。
7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。
# 支付宝支付链接前端对接说明
## 1. 对接范围
本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK后端会返回已签名的支付宝 WAP 支付链接。
覆盖场景:
1. 套餐订单支付
2. 钱包充值
3. 强制充值购包场景
微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`
具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。
## 2. 支付方式取值
前端统一使用 `payment_method` 区分支付方式:
| 值 | 含义 | 前端处理 |
| --- | --- | --- |
| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 |
| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 |
| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 |
注意事项:
- `app_type` 只在微信支付时需要。
- 支付宝支付时不需要传 `app_type`
- 支付宝支付返回的是支付链接,不是 JSAPI 参数。
## 3. 前端需要关注的响应字段
当选择支付宝支付时,接口响应中会返回 `payment_link`
```json
{
"payment_link": {
"payment_no": "支付单号",
"qr_link": "支付宝支付链接,可用于生成二维码",
"copy_link": "支付宝支付链接,可复制或直接打开",
"pay_expire_at": "支付链接过期时间RFC3339 格式"
}
}
```
字段说明:
| 字段 | 用途 |
| --- | --- |
| `payment_no` | 支付单号,问题排查 / 客服查询使用 |
| `qr_link` | 前端可用该链接生成二维码 |
| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 |
| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 |
## 4. 推荐交互流程
### 4.1 支付宝支付流程
前端拿到 `payment_link` 后:
1. H5 / 浏览器场景:直接跳转 `copy_link`
支付成功后:
- 不要只依赖支付宝返回页判断支付成功。
- 最终支付状态以服务端订单 / 充值记录状态为准。
- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。
## 5. 各业务场景说明
### 5.1 套餐订单支付
普通套餐下单后,如果需要支付,前端调用订单支付接口,并传:
```json
{
"payment_method": "alipay"
}
```
支付宝支付响应示例:
```json
{
"payment_method": "alipay",
"payment_link": {
"payment_no": "...",
"qr_link": "...",
"copy_link": "...",
"pay_expire_at": "..."
}
}
```
前端根据 `payment_link` 发起支付宝支付。
### 5.2 钱包充值
创建钱包充值单时传:
```json
{
"payment_method": "alipay"
}
```
支付宝场景返回 `payment_link`;微信场景返回 `pay_config`
### 5.3 强制充值购包
创建订单时,如果后端判断需要强制充值,前端可指定:
```json
{
"payment_method": "alipay"
}
```
如果是支付宝强充,响应中会返回:
```json
{
"order_type": "recharge",
"recharge": {},
"payment_link": {}
}
```
前端按支付宝支付链接流程处理。
## 6. 前端展示建议
支付宝支付页面建议展示:
- 支付金额
- 支付宝二维码或“去支付宝支付”按钮
- 支付链接过期倒计时
- “我已完成支付”按钮
- “重新获取支付链接”按钮
点击“我已完成支付”时,不直接判定成功,应查询服务端状态。
## 7. 注意事项
1. 金额单位仍然是 **分**
2. 支付宝支付不需要 `app_type`
3. `pay_config` 只用于微信支付。
4. `payment_link` 只用于支付宝支付。
5. 支付状态以服务端为准,不以前端跳转结果为准。
6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。
7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。

View File

@@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<title></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"/>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>window.__WX_ENTRY_URL__ = window.location.href.split('#')[0]</script>
</head>
<body>
<div id="app"><!--app-html--></div>

62
main.js
View File

@@ -1,55 +1,11 @@
import App from './App'
import uviewPlus from 'uview-plus'
const TAB_BAR_STYLE_NOT_FOUND_ERROR = 'setTabBarStyle:fail not TabBar page'
const shouldIgnoreTabBarStyleError = (error) => {
const message = typeof error === 'string'
? error
: error?.errMsg || error?.message || ''
return message.includes(TAB_BAR_STYLE_NOT_FOUND_ERROR)
}
const patchSafeSetTabBarStyle = () => {
if (typeof uni === 'undefined' || typeof uni.setTabBarStyle !== 'function') {
return
}
if (uni.setTabBarStyle.__upSafePatched) {
return
}
const originalSetTabBarStyle = uni.setTabBarStyle.bind(uni)
const safeSetTabBarStyle = (options = {}) => {
try {
const result = originalSetTabBarStyle(options)
if (result && typeof result.catch === 'function') {
return result.catch((error) => {
if (shouldIgnoreTabBarStyleError(error)) {
return undefined
}
return Promise.reject(error)
})
}
return result
} catch (error) {
if (shouldIgnoreTabBarStyleError(error)) {
return undefined
}
throw error
}
}
safeSetTabBarStyle.__upSafePatched = true
uni.setTabBarStyle = safeSetTabBarStyle
}
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
patchSafeSetTabBarStyle()
const app = new Vue({
...App
})
@@ -59,24 +15,10 @@ app.$mount()
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
patchSafeSetTabBarStyle()
const app = createSSRApp(App)
// 使用 uview-plus (版本 3.4.0+)
app.use(uviewPlus, () => {
return {
options: {
config: {
// 默认单位 rpx
unit: 'rpx'
},
props: {
// 可以在这里配置组件默认属性
}
}
}
})
app.use(uviewPlus)
return {
app
}
}
// #endif
// #endif

View File

@@ -1,5 +1,5 @@
{
"name" : "c-xmot-iot",
"name" : "device-voice-h5",
"appid" : "__UNI__45F0251",
"description" : "",
"versionName" : "1.0.0",
@@ -17,10 +17,7 @@
"delay" : 0
},
/* */
"modules" : {
"Barcode" : {},
"Camera" : {}
},
"modules" : {},
/* */
"distribute" : {
/* android */
@@ -100,8 +97,6 @@
}
}
},
"sdkConfigs" : {
"maps" : {}
}
"sdkConfigs" : {}
}
}

View File

@@ -1,456 +0,0 @@
# OpenSpec Instructions
Instructions for AI coding assistants using OpenSpec for spec-driven development.
## TL;DR Quick Checklist
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
- Decide scope: new capability vs modify existing capability
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
- Validate: `openspec validate [change-id] --strict` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
### Stage 1: Creating Changes
Create proposal when you need to:
- Add features or functionality
- Make breaking changes (API, schema)
- Change architecture or patterns
- Optimize performance (changes behavior)
- Update security patterns
Triggers (examples):
- "Help me create a change proposal"
- "Help me plan a change"
- "Help me create a proposal"
- "I want to create a spec proposal"
- "I want to create a spec"
Loose matching guidance:
- Contains one of: `proposal`, `change`, `spec`
- With one of: `create`, `plan`, `make`, `start`, `help`
Skip proposal for:
- Bug fixes (restore intended behavior)
- Typos, formatting, comments
- Dependency updates (non-breaking)
- Configuration changes
- Tests for existing behavior
**Workflow**
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/``changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
- Run `openspec validate --strict` to confirm the archived change passes checks
## Before Any Task
**Context Checklist:**
- [ ] Read relevant specs in `specs/[capability]/spec.md`
- [ ] Check pending changes in `changes/` for conflicts
- [ ] Read `openspec/project.md` for conventions
- [ ] Run `openspec list` to see active changes
- [ ] Run `openspec list --specs` to see existing capabilities
**Before Creating Specs:**
- Always check if capability already exists
- Prefer modifying existing specs over creating duplicates
- Use `openspec show [spec]` to review current state
- If request is ambiguous, ask 12 clarifying questions before scaffolding
### Search Guidance
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
- Show details:
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
- Change: `openspec show <change-id> --json --deltas-only`
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
## Quick Start
### CLI Commands
```bash
# Essential commands
openspec list # List active changes
openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec validate [item] # Validate changes or specs
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
openspec update [path] # Update instruction files
# Interactive mode
openspec show # Prompts for selection
openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
```
### Command Flags
- `--json` - Machine-readable output
- `--type change|spec` - Disambiguate items
- `--strict` - Comprehensive validation
- `--no-interactive` - Disable prompts
- `--skip-specs` - Archive without spec updates
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
```
openspec/
├── project.md # Project conventions
├── specs/ # Current truth - what IS built
│ └── [capability]/ # Single focused capability
│ ├── spec.md # Requirements and scenarios
│ └── design.md # Technical patterns
├── changes/ # Proposals - what SHOULD change
│ ├── [change-name]/
│ │ ├── proposal.md # Why, what, impact
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional; see criteria)
│ │ └── specs/ # Delta changes
│ │ └── [capability]/
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
│ └── archive/ # Completed changes
```
## Creating Change Proposals
### Decision Tree
```
New request?
├─ Bug fix restoring spec behavior? → Fix directly
├─ Typo/format/comment? → Fix directly
├─ New feature/capability? → Create proposal
├─ Breaking change? → Create proposal
├─ Architecture change? → Create proposal
└─ Unclear? → Create proposal (safer)
```
### Proposal Structure
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
2. **Write proposal.md:**
```markdown
# Change: [Brief description of change]
## Why
[1-2 sentences on problem/opportunity]
## What Changes
- [Bullet list of changes]
- [Mark breaking changes with **BREAKING**]
## Impact
- Affected specs: [list capabilities]
- Affected code: [key files/systems]
```
3. **Create spec deltas:** `specs/[capability]/spec.md`
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL provide...
#### Scenario: Success case
- **WHEN** user performs action
- **THEN** expected result
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement]
## REMOVED Requirements
### Requirement: Old Feature
**Reason**: [Why removing]
**Migration**: [How to handle]
```
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
4. **Create tasks.md:**
```markdown
## 1. Implementation
- [ ] 1.1 Create database schema
- [ ] 1.2 Implement API endpoint
- [ ] 1.3 Add frontend component
- [ ] 1.4 Write tests
```
5. **Create design.md when needed:**
Create `design.md` if any of the following apply; otherwise omit it:
- Cross-cutting change (multiple services/modules) or a new architectural pattern
- New external dependency or significant data model changes
- Security, performance, or migration complexity
- Ambiguity that benefits from technical decisions before coding
Minimal `design.md` skeleton:
```markdown
## Context
[Background, constraints, stakeholders]
## Goals / Non-Goals
- Goals: [...]
- Non-Goals: [...]
## Decisions
- Decision: [What and why]
- Alternatives considered: [Options + rationale]
## Risks / Trade-offs
- [Risk] → Mitigation
## Migration Plan
[Steps, rollback]
## Open Questions
- [...]
```
## Spec File Format
### Critical: Scenario Formatting
**CORRECT** (use #### headers):
```markdown
#### Scenario: User login success
- **WHEN** valid credentials provided
- **THEN** return JWT token
```
**WRONG** (don't use bullets or bold):
```markdown
- **Scenario: User login** ❌
**Scenario**: User login ❌
### Scenario: User login ❌
```
Every requirement MUST have at least one scenario.
### Requirement Wording
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
### Delta Operations
- `## ADDED Requirements` - New capabilities
- `## MODIFIED Requirements` - Changed behavior
- `## REMOVED Requirements` - Deprecated features
- `## RENAMED Requirements` - Name changes
Headers matched with `trim(header)` - whitespace ignored.
#### When to use ADDED vs MODIFIED
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you arent explicitly changing the existing requirement, add a new requirement under ADDED instead.
Authoring a MODIFIED requirement correctly:
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
Example for RENAMED:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Login`
- TO: `### Requirement: User Authentication`
```
## Troubleshooting
### Common Errors
**"Change must have at least one delta"**
- Check `changes/[name]/specs/` exists with .md files
- Verify files have operation prefixes (## ADDED Requirements)
**"Requirement must have at least one scenario"**
- Check scenarios use `#### Scenario:` format (4 hashtags)
- Don't use bullet points or bold for scenario headers
**Silent scenario parsing failures**
- Exact format required: `#### Scenario: Name`
- Debug with: `openspec show [change] --json --deltas-only`
### Validation Tips
```bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
# Check specific requirement
openspec show [spec] --json -r 1
```
## Happy Path Script
```bash
# 1) Explore current state
openspec spec list --long
openspec list
# Optional full-text search:
# rg -n "Requirement:|Scenario:" openspec/specs
# rg -n "^#|Requirement:" openspec/changes
# 2) Choose change id and scaffold
CHANGE=add-two-factor-auth
mkdir -p openspec/changes/$CHANGE/{specs/auth}
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
# 3) Add deltas (example)
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
## ADDED Requirements
### Requirement: Two-Factor Authentication
Users MUST provide a second factor during login.
#### Scenario: OTP required
- **WHEN** valid credentials are provided
- **THEN** an OTP challenge is required
EOF
# 4) Validate
openspec validate $CHANGE --strict
```
## Multi-Capability Example
```
openspec/changes/add-2fa-notify/
├── proposal.md
├── tasks.md
└── specs/
├── auth/
│ └── spec.md # ADDED: Two-Factor Authentication
└── notifications/
└── spec.md # ADDED: OTP email notification
```
auth/spec.md
```markdown
## ADDED Requirements
### Requirement: Two-Factor Authentication
...
```
notifications/spec.md
```markdown
## ADDED Requirements
### Requirement: OTP Email Notification
...
```
## Best Practices
### Simplicity First
- Default to <100 lines of new code
- Single-file implementations until proven insufficient
- Avoid frameworks without clear justification
- Choose boring, proven patterns
### Complexity Triggers
Only add complexity with:
- Performance data showing current solution too slow
- Concrete scale requirements (>1000 users, >100MB data)
- Multiple proven use cases requiring abstraction
### Clear References
- Use `file.ts:42` format for code locations
- Reference specs as `specs/auth/spec.md`
- Link related changes and PRs
### Capability Naming
- Use verb-noun: `user-auth`, `payment-capture`
- Single purpose per capability
- 10-minute understandability rule
- Split if description needs "AND"
### Change ID Naming
- Use kebab-case, short and descriptive: `add-two-factor-auth`
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
## Tool Selection Guide
| Task | Tool | Why |
|------|------|-----|
| Find files by pattern | Glob | Fast pattern matching |
| Search code content | Grep | Optimized regex search |
| Read specific files | Read | Direct file access |
| Explore unknown scope | Task | Multi-step investigation |
## Error Recovery
### Change Conflicts
1. Run `openspec list` to see active changes
2. Check for overlapping specs
3. Coordinate with change owners
4. Consider combining proposals
### Validation Failures
1. Run with `--strict` flag
2. Check JSON output for details
3. Verify spec file format
4. Ensure scenarios properly formatted
### Missing Context
1. Read project.md first
2. Check related specs
3. Review recent archives
4. Ask for clarification
## Quick Reference
### Stage Indicators
- `changes/` - Proposed, not yet built
- `specs/` - Built and deployed
- `archive/` - Completed changes
### File Purposes
- `proposal.md` - Why and what
- `tasks.md` - Implementation steps
- `design.md` - Technical decisions
- `spec.md` - Requirements and behavior
### CLI Essentials
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec validate --strict # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.

View File

@@ -1,25 +0,0 @@
## Why
The front end currently supports WeChat payment and wallet balance payment, but it does not support the new backend Alipay WAP payment-link flow. This prevents users from completing package purchase and wallet recharge scenarios when Alipay is the selected method.
## What Changes
- Add Alipay as a supported payment method in package purchase, order list immediate payment, and wallet recharge flows
- Handle backend `payment_link` responses for Alipay while preserving existing WeChat `pay_config` handling
- Keep wallet balance payment behavior unchanged for package purchase
- Refresh payment-related page state from backend status after the user returns from the Alipay payment link flow
- Preserve current order creation, strong recharge, validation, toast, and list refresh behavior except where Alipay-specific branching is required
## Capabilities
### New Capabilities
- `alipay-payment-link`: Support backend-provided Alipay payment links across package, order, and wallet payment entry points
### Modified Capabilities
## Impact
- Affected code: `pages/package-order/package-order.vue`, `pages/order-list/order-list.vue`, `pages/my-wallet/my-wallet.vue`, `utils/payment.js`, `api/modules/wallet.js`
- Affected UI flows: package payment method popup, order list immediate payment, wallet recharge popup, wallet recharge order immediate payment
- No backend API path changes expected
- Existing WeChat and wallet payment behavior must remain available

View File

@@ -1,58 +0,0 @@
## ADDED Requirements
### Requirement: Package payment SHALL support Alipay payment links
The system SHALL allow users to choose Alipay during package purchase and SHALL handle backend Alipay responses through `payment_link` while preserving the existing WeChat and wallet payment branches.
#### Scenario: User pays a package order with Alipay
- **WHEN** the user selects `alipay` in the package payment popup and the backend returns `payment_method: "alipay"` with `payment_link`
- **THEN** the system SHALL open or present the returned Alipay payment link for the user
- **AND** the system SHALL NOT require WeChat `pay_config` fields for this branch
#### Scenario: User pays a package order with wallet balance
- **WHEN** the user selects `wallet` and the backend completes the package payment without a third-party redirect
- **THEN** the system SHALL preserve the existing wallet payment success flow
- **AND** the system SHALL refresh relevant wallet-related page state after success
#### Scenario: Strong recharge branch returns an Alipay payment link
- **WHEN** package order creation enters the strong recharge branch and the backend returns `order_type: "recharge"` with `payment_link`
- **THEN** the system SHALL use the Alipay payment-link flow for that recharge branch
- **AND** the system SHALL preserve the existing strong recharge messaging and follow-up refresh behavior
### Requirement: Pending order payment SHALL support Alipay payment links
The system SHALL allow users to continue payment for pending package orders from the order list through an Alipay payment link flow.
#### Scenario: User continues a pending order with Alipay
- **WHEN** the user starts payment for a pending order from the order list and the backend returns `payment_method: "alipay"` with `payment_link`
- **THEN** the system SHALL open or present the returned payment link
- **AND** the system SHALL refresh the order list from backend status after the user completes or retries the flow
### Requirement: Wallet recharge flows SHALL support Alipay payment links
The system SHALL support Alipay both when creating a new wallet recharge order and when retrying payment for a pending recharge order.
#### Scenario: User creates a new wallet recharge with Alipay
- **WHEN** the user confirms a wallet recharge with `payment_method: "alipay"` and the backend returns `payment_link`
- **THEN** the system SHALL open or present the returned payment link
- **AND** the system SHALL preserve the existing recharge validation rules and amount checks
#### Scenario: User retries a pending recharge order with Alipay
- **WHEN** the user continues payment for a pending recharge order and the backend returns `payment_link`
- **THEN** the system SHALL open or present the returned Alipay payment link
- **AND** the system SHALL refresh wallet detail and recharge list state from backend data after the user completes or retries the flow
### Requirement: Payment method branching SHALL distinguish WeChat and Alipay contracts
The system SHALL treat WeChat and Alipay payment preparation responses as different contracts and SHALL determine final payment success from backend order or recharge status instead of the redirect result alone.
#### Scenario: WeChat payment returns pay_config
- **WHEN** the backend returns `payment_method: "wechat"` with `pay_config`
- **THEN** the system SHALL continue to use the existing WeChat H5 payment invocation flow
- **AND** the system SHALL preserve the current `app_type` behavior for WeChat requests
#### Scenario: Alipay payment returns payment_link
- **WHEN** the backend returns `payment_method: "alipay"` with `payment_link`
- **THEN** the system SHALL use the returned link fields instead of expecting `pay_config`
- **AND** the system SHALL NOT depend on `app_type` for the Alipay branch
#### Scenario: User returns after Alipay jump
- **WHEN** the user comes back to the application after opening the Alipay payment link
- **THEN** the system SHALL refresh the relevant order or recharge data from backend APIs
- **AND** the system SHALL determine whether the payment succeeded from refreshed backend status

View File

@@ -1,29 +0,0 @@
## 1. Shared Payment Branching
- [x] 1.1 Add shared front-end handling for Alipay `payment_link` responses alongside the existing WeChat `pay_config` flow
- [x] 1.2 Ensure Alipay requests do not depend on `app_type` and existing WeChat requests continue to work unchanged
## 2. Package Purchase Flow
- [x] 2.1 Add Alipay as a selectable payment method in `pages/package-order/package-order.vue`
- [x] 2.2 Handle normal package order payment responses for `wechat`, `wallet`, and `alipay`
- [x] 2.3 Handle strong recharge branches that return `payment_link` for Alipay
## 3. Order List Flow
- [x] 3.1 Add an order-list payment entry that lets the user continue pending orders with Alipay
- [x] 3.2 Refresh order list state from backend data after the user completes or retries the Alipay flow
## 4. Wallet Flow
- [x] 4.1 Add Alipay as a selectable payment method in the wallet recharge popup
- [x] 4.2 Handle Alipay recharge creation responses in `confirmRecharge`
- [x] 4.3 Handle Alipay payment retries for pending recharge orders in `handleRechargePayment`
- [x] 4.4 Refresh wallet detail and recharge list state from backend data after the Alipay flow
## 5. Regression Verification
- [x] 5.1 Verify WeChat package payment still uses `pay_config`
- [x] 5.2 Verify wallet-balance package payment remains unchanged
- [x] 5.3 Verify Alipay package payment, order-list payment, wallet recharge, and recharge retry can all open a valid payment link flow
- [x] 5.4 Verify payment success is determined by refreshed backend status instead of the link jump result

View File

@@ -1,26 +0,0 @@
## Why
The wallet recharge confirmation, package payment confirmation, order list immediate payment, and wallet recharge order immediate payment flows can be triggered repeatedly when users tap quickly or when the page is slow to respond. This can cause duplicate order creation, duplicate payment preparation, repeated payment invocation, and inconsistent UI state.
## What Changes
- Add a front-end submit guard for the wallet recharge confirmation flow in `pages/my-wallet/my-wallet.vue`
- Add a front-end submit guard for the package payment confirmation flow in `pages/package-order/package-order.vue`
- Add a front-end submit guard for the order list immediate payment flow in `pages/order-list/order-list.vue`
- Add a front-end submit guard for the wallet recharge order immediate payment flow in `pages/my-wallet/my-wallet.vue`
- Standardize button behavior during submission so repeated taps are ignored until the current flow ends
- Require submit state recovery for success, failure, cancel, validation failure, list refresh, and modal re-entry paths
## Capabilities
### New Capabilities
- `payment-submit-guard`: Prevent duplicate submission in recharge and payment entry points across wallet, package order, and order list flows
### Modified Capabilities
## Impact
- Affected code: `pages/my-wallet/my-wallet.vue`, `pages/package-order/package-order.vue`, `pages/order-list/order-list.vue`
- Affected UI flows: wallet recharge modal, wallet recharge order list immediate payment, package payment method modal, order list immediate payment
- No backend API changes
- No payment business rule changes

View File

@@ -1,81 +0,0 @@
## ADDED Requirements
### Requirement: Wallet recharge confirmation SHALL prevent duplicate submission
The system SHALL prevent repeated taps on the wallet recharge confirmation button from starting more than one recharge submission flow at the same time.
#### Scenario: User taps confirm recharge repeatedly during submission
- **WHEN** the user taps the wallet recharge confirmation button multiple times before the current `confirmRecharge` flow completes
- **THEN** the system SHALL execute only the first submission flow
- **AND** the system SHALL ignore subsequent taps until the current flow ends
#### Scenario: Wallet recharge confirmation enters submitting state
- **WHEN** the first valid wallet recharge confirmation tap is accepted
- **THEN** the system SHALL mark the wallet recharge confirmation flow as submitting immediately
- **AND** the system SHALL present the confirmation button as non-repeatable during that state
#### Scenario: Wallet recharge confirmation recovers after terminal outcome
- **WHEN** the wallet recharge flow ends because of success, failure, cancellation, local validation failure, or modal re-entry
- **THEN** the system SHALL clear the submitting state
- **AND** the user SHALL be able to start a new recharge attempt
### Requirement: Package payment confirmation SHALL prevent duplicate submission
The system SHALL prevent repeated taps on the package payment confirmation button from starting more than one package payment submission flow at the same time.
#### Scenario: User taps confirm payment repeatedly during submission
- **WHEN** the user taps the package payment confirmation button multiple times before the current `confirmPay` flow completes
- **THEN** the system SHALL execute only the first submission flow
- **AND** the system SHALL ignore subsequent taps until the current flow ends
#### Scenario: Package payment confirmation enters submitting state
- **WHEN** the first valid package payment confirmation tap is accepted
- **THEN** the system SHALL mark the package payment confirmation flow as submitting immediately
- **AND** the system SHALL present the confirmation button as non-repeatable during that state
#### Scenario: Package payment confirmation recovers after terminal outcome
- **WHEN** the package payment flow ends because of success, failure, cancellation, validation failure, strong recharge prompt completion, or modal re-entry
- **THEN** the system SHALL clear the submitting state
- **AND** the user SHALL be able to start a new payment attempt
### Requirement: Order list immediate payment SHALL prevent duplicate submission
The system SHALL prevent repeated taps on an order list item's immediate payment button from starting more than one payment submission flow for the current order at the same time.
#### Scenario: User taps order list immediate payment repeatedly during submission
- **WHEN** the user taps the immediate payment button multiple times before the current `handleOrderPayment` flow completes
- **THEN** the system SHALL execute only the first submission flow
- **AND** the system SHALL ignore subsequent taps until the current flow ends
#### Scenario: Order list immediate payment enters submitting state
- **WHEN** the first valid order list immediate payment tap is accepted
- **THEN** the system SHALL mark the current order payment flow as submitting immediately
- **AND** the system SHALL present the triggered immediate payment button as non-repeatable during that state
#### Scenario: Order list immediate payment recovers after terminal outcome
- **WHEN** the order list payment flow ends because of success, failure, cancellation, list refresh, or page re-entry
- **THEN** the system SHALL clear the submitting state
- **AND** the user SHALL be able to start a new payment attempt if the order remains payable
### Requirement: Wallet recharge order immediate payment SHALL prevent duplicate submission
The system SHALL prevent repeated taps on a wallet recharge order item's immediate payment button from starting more than one recharge order payment flow for the current recharge order at the same time.
#### Scenario: User taps wallet recharge order immediate payment repeatedly during submission
- **WHEN** the user taps the immediate payment button multiple times before the current `handleRechargePayment` flow completes
- **THEN** the system SHALL execute only the first submission flow
- **AND** the system SHALL ignore subsequent taps until the current flow ends
#### Scenario: Wallet recharge order immediate payment enters submitting state
- **WHEN** the first valid wallet recharge order immediate payment tap is accepted
- **THEN** the system SHALL mark the current recharge order payment flow as submitting immediately
- **AND** the system SHALL present the triggered immediate payment button as non-repeatable during that state
#### Scenario: Wallet recharge order immediate payment recovers after terminal outcome
- **WHEN** the wallet recharge order payment flow ends because of success, failure, cancellation, list refresh, or page re-entry
- **THEN** the system SHALL clear the submitting state
- **AND** the user SHALL be able to start a new payment attempt if the recharge order remains payable
### Requirement: Submit guards SHALL NOT change existing payment business rules
The system SHALL preserve the current recharge validation, recharge order payment, order list payment, order creation, strong recharge decision, wallet payment, WeChat payment, toast, modal, and balance refresh behavior while adding submit guards.
#### Scenario: Existing business validation remains unchanged
- **WHEN** the user submits with an invalid recharge amount or reaches an existing payment business rule branch
- **THEN** the system SHALL keep the current validation and messaging behavior
- **AND** the duplicate submission guard SHALL only affect repeated-trigger protection

View File

@@ -1,31 +0,0 @@
## 1. Wallet Recharge Confirmation Submit Guard
- [x] 1.1 Add a dedicated submitting state for the wallet recharge confirmation flow
- [x] 1.2 Ignore repeated taps while `confirmRecharge` is already running
- [x] 1.3 Bind the confirmation button state to the submitting state and restore it on every exit path
## 2. Package Payment Confirmation Submit Guard
- [x] 2.1 Add a dedicated submitting state for the package payment confirmation flow
- [x] 2.2 Ignore repeated taps while `confirmPay` is already running
- [x] 2.3 Bind the confirmation button state to the submitting state and restore it on every exit path
## 3. Order List Immediate Payment Submit Guard
- [x] 3.1 Add a dedicated submitting state for order list immediate payment
- [x] 3.2 Ignore repeated taps while `handleOrderPayment` is already running for the current payment flow
- [x] 3.3 Bind the order list immediate payment button state to the submitting state and restore it on every exit path
## 4. Wallet Recharge Order Immediate Payment Submit Guard
- [x] 4.1 Add a dedicated submitting state for wallet recharge order immediate payment
- [x] 4.2 Ignore repeated taps while `handleRechargePayment` is already running for the current payment flow
- [x] 4.3 Bind the recharge order immediate payment button state to the submitting state and restore it on every exit path
## 5. Regression Verification
- [x] 5.1 Verify repeated taps only trigger one effective submit in the wallet recharge modal
- [x] 5.2 Verify repeated taps only trigger one effective submit in the package payment modal
- [x] 5.3 Verify repeated taps only trigger one effective submit in the order list immediate payment flow
- [x] 5.4 Verify repeated taps only trigger one effective submit in the wallet recharge order immediate payment flow
- [x] 5.5 Verify users can retry after validation failure, payment cancel, payment failure, list refresh, and modal reopen

View File

@@ -1,49 +0,0 @@
## Context
The current device exchange popup stores recipient name, phone, and address in three flat fields, with `recipient_address` being a single string. The shipping submit API currently accepts only `recipient_name`, `recipient_phone`, and `recipient_address`, so the frontend must remain compatible unless the backend contract changes later.
The homepage already loads `/api/c/v1/asset/info` through `assetApi.getInfo(identifier)`. The UI shows package name and expiry date in the top summary card, but the request now also returns `data.iccid`, which should be exposed without adding a second asset query.
## Goals / Non-Goals
- Goals:
- Add a structured province/city/district selector using `element-china-area-data`
- Keep a separate detailed-address field for device exchange shipping info
- Allow users to paste one shipping-info string and prefill recipient name, phone, and address fields
- Preserve compatibility with the existing exchange shipping submit endpoint
- Show `ICCID` above the package name in the homepage top summary card
- Non-Goals:
- Redesign the backend exchange shipping API
- Add OCR or image-based recognition for shipping labels
- Normalize all historical address data outside the device exchange popup
## Decisions
- Decision: Use `element-china-area-data` as the source for province/city/district options.
Why: The request explicitly requires that dataset, and a static local area dataset avoids introducing extra network dependencies.
- Decision: Keep `/api/c/v1/exchange/{id}/shipping-info` unchanged and compose `recipient_address` on the client.
Why: The existing API already accepts one address string, so the frontend can implement the new UX without blocking on backend changes.
- Decision: Recognize pasted shipping text with best-effort client-side parsing for mainland China phone numbers and common copied-address formats.
Why: Users often copy a single line or multi-line string from chat or e-commerce apps, and partial automation reduces manual entry while still allowing correction.
- Decision: When reopening existing saved shipping info, prefill split fields with best-effort parsing and preserve the original address content if a full region split cannot be derived.
Why: Existing records are already stored as a combined string, so the UI needs a no-data-loss compatibility path during the transition.
- Decision: Use `data.iccid` from the existing asset info response as the homepage summary source of truth.
Why: The user explicitly named that field and endpoint, and it avoids mixing multiple ICCID sources in the header card.
## Risks / Trade-offs
- Pasted shipping strings vary in format, so recognition must remain best-effort and allow manual correction before submission.
- Some previously saved combined addresses may not map cleanly back to province/city/district selections, so fallback behavior must preserve the original text.
- Adding a new area dataset dependency increases bundle size slightly, but it keeps address selection deterministic and local.
## Migration Plan
1. Add the `element-china-area-data` dependency and wire it into the device exchange popup.
2. Introduce split address state, pasted-text parsing, and composed submit payload handling.
3. Update homepage asset state to read `data.iccid` and render it above package name.
4. Verify that existing exchange records remain editable without losing previously saved address content.

View File

@@ -1,28 +0,0 @@
## Why
The device exchange popup currently collects the shipping address as a single free-text field. This makes Chinese address entry error-prone, does not support selecting province/city/district from a standard area dataset, and forces users to manually rewrite recipient details even when they already copied a full shipping string from another app.
The homepage top summary card already loads asset information from `/api/c/v1/asset/info`, but it does not surface the `ICCID` value even though the response now provides `data.iccid`. Users need that value visible above the package name on the homepage.
## What Changes
- Replace the single shipping-address input in `pages/device-exchange/device-exchange.vue` with province/city/district selection backed by `element-china-area-data` plus a separate detailed-address input
- Add a pasteable shipping-info input so the page can recognize and prefill recipient name, phone number, and address from one copied string
- Keep the existing exchange shipping submission API contract and assemble the selected region text together with the detailed address into `recipient_address`
- Add an `ICCID` line above the package name in the homepage top summary card
- Use `data.iccid` from `/api/c/v1/asset/info?identifier=...` as the homepage `ICCID` source of truth
## Capabilities
### New Capabilities
- `device-exchange-shipping-entry`: Define structured shipping entry, pasted shipping-text recognition, and payload composition for the device exchange popup
- `index-asset-summary`: Define homepage summary display rules for `ICCID`
### Modified Capabilities
## Impact
- Affected code: `pages/device-exchange/device-exchange.vue`, `pages/index/index.vue`, `components/UserInfoCard.vue`, related address parsing helpers if introduced
- Affected dependencies: `element-china-area-data`
- Affected API usage: `/api/c/v1/exchange/{id}/shipping-info`, `/api/c/v1/asset/info`
- No backend API contract change is required if the frontend continues submitting a composed `recipient_address` string

View File

@@ -1,50 +0,0 @@
## ADDED Requirements
### Requirement: Device exchange shipping entry SHALL collect a structured recipient address
The system SHALL collect recipient name, recipient phone number, a province/city/district selection sourced from `element-china-area-data`, and a separate detailed address when the user fills shipping information for a pending device exchange.
#### Scenario: User opens the shipping popup for a pending exchange
- **WHEN** the device exchange record is in the state that allows filling shipping information
- **AND** the user opens the shipping popup
- **THEN** the popup SHALL show editable fields for recipient name, recipient phone number, province/city/district, and detailed address
#### Scenario: User attempts to submit without a complete address
- **WHEN** the user submits the shipping popup without selecting province/city/district or without entering a detailed address
- **THEN** the system SHALL block submission
- **AND** the system SHALL tell the user that the shipping address is incomplete
### Requirement: Device exchange shipping entry SHALL support pasted shipping-text recognition
The system SHALL provide a way for the user to paste one shipping-info string and SHALL attempt to recognize recipient name, mainland China phone number, and address content from that string into editable form fields.
#### Scenario: Pasted shipping text contains recognizable recipient information
- **WHEN** the user pastes a shipping-info string containing a recipient name, an 11-digit mainland China phone number, and address content
- **THEN** the system SHALL prefill the recognized recipient name and phone number
- **AND** the system SHALL prefill the recognized address content into the structured address form as far as it can infer
#### Scenario: Pasted shipping text is only partially recognizable
- **WHEN** the user pastes a shipping-info string but the system cannot confidently extract every required field
- **THEN** the system SHALL preserve the fields it can recognize
- **AND** the user SHALL still be able to manually complete or correct the remaining fields before submission
### Requirement: Device exchange shipping submission SHALL preserve the existing API contract
The system SHALL continue submitting `recipient_name`, `recipient_phone`, and `recipient_address` to `/api/c/v1/exchange/{id}/shipping-info`, and SHALL compose `recipient_address` from the selected province/city/district text together with the detailed address.
#### Scenario: User submits valid shipping information
- **WHEN** the user submits the popup with a valid recipient name, a valid mainland China phone number, a selected province/city/district, and a detailed address
- **THEN** the system SHALL call `/api/c/v1/exchange/{id}/shipping-info`
- **AND** the payload SHALL keep `recipient_name` and `recipient_phone`
- **AND** the payload SHALL send a composed `recipient_address` string containing the selected region text and the detailed address
### Requirement: Existing exchange shipping information SHALL remain editable without data loss
The system SHALL preserve previously saved shipping information when reopening the popup and SHALL support best-effort conversion from a previously saved combined address string into the new structured address fields.
#### Scenario: Existing saved address can be split into region and detail
- **WHEN** the popup opens for an exchange record that already has a saved `recipient_address`
- **AND** the saved address can be matched to province/city/district data
- **THEN** the system SHALL prefill the region selection and the detailed address
#### Scenario: Existing saved address cannot be fully split
- **WHEN** the popup opens for an exchange record that already has a saved `recipient_address`
- **AND** the saved address cannot be confidently split into province/city/district plus detailed address
- **THEN** the system SHALL preserve the original saved address content for manual correction
- **AND** the user SHALL still be able to complete a valid structured address before resubmitting

View File

@@ -1,23 +0,0 @@
## ADDED Requirements
### Requirement: Homepage top summary SHALL display ICCID above the package name
The system SHALL display an `ICCID` line above the package name in the homepage top summary card after asset information is loaded.
#### Scenario: Asset info returns an ICCID value
- **WHEN** the homepage loads asset information successfully
- **AND** the response contains `data.iccid`
- **THEN** the top summary card SHALL display `ICCID: <value>` above the package name
#### Scenario: Asset info does not return an ICCID value
- **WHEN** the homepage loads asset information successfully
- **AND** `data.iccid` is empty or missing
- **THEN** the top summary card SHALL still display the `ICCID` field
- **AND** the field SHALL show a stable fallback value
### Requirement: Homepage ICCID SHALL use the asset info response as the source of truth
The system SHALL source the homepage `ICCID` value from `data.iccid` returned by `/api/c/v1/asset/info?identifier=...`.
#### Scenario: Homepage refreshes asset information
- **WHEN** the homepage reads or refreshes asset information from `/api/c/v1/asset/info`
- **THEN** the `ICCID` shown in the top summary card SHALL be updated from `data.iccid`
- **AND** the header SHALL NOT depend on deriving the display value from a different ICCID field for this summary position

View File

@@ -1,20 +0,0 @@
## 1. Device Exchange Shipping Entry
- [x] 1.1 Add `element-china-area-data` and integrate province/city/district selection into `pages/device-exchange/device-exchange.vue`
- [x] 1.2 Replace the single shipping-address input with structured region selection plus a separate detailed-address input
- [x] 1.3 Add a pasted shipping-info input and parse recipient name, recipient phone, and address content into editable form fields
- [x] 1.4 Compose the selected region text and detailed address into `recipient_address` when submitting `/api/c/v1/exchange/{id}/shipping-info`
- [x] 1.5 Preserve edit compatibility for previously saved combined addresses
## 2. Homepage ICCID Summary
- [x] 2.1 Read `data.iccid` from `assetApi.getInfo(identifier)` into homepage state
- [x] 2.2 Render `ICCID` above the package name in `components/UserInfoCard.vue`
- [x] 2.3 Show a stable fallback when `data.iccid` is absent
## 3. Regression Verification
- [x] 3.1 Verify the device exchange popup blocks submission when region selection or detailed address is incomplete
- [x] 3.2 Verify a pasted shipping string can prefill recipient name, phone number, and address with manual correction still available
- [x] 3.3 Verify the exchange shipping submit payload remains compatible with the existing backend contract
- [x] 3.4 Verify the homepage top summary shows `ICCID` from `/api/c/v1/asset/info` above the package name

View File

@@ -1,136 +0,0 @@
# Exchange Flow Type Support - Implementation Summary
## 实施完成 ✅
### 变更概述
根据后端换货流程升级,前端已完成对新增字段的支持:
- `flow_type`: 流程类型shipping/direct
- `flow_type_name`: 流程类型名称
- `shipped_at`: 发货时间
- `completed_at`: 完成时间
### 已完成的工作
#### 1. 创建 OpenSpec 文档
-`proposal.md` - 提案说明
-`specs/exchange-flow-type-display/spec.md` - 详细规格
-`tasks.md` - 任务清单
#### 2. 代码实现
**修改文件:** `pages/device-exchange/device-exchange.vue`
**新增显示内容:**
```vue
<!-- 换货类型 - 始终显示 -->
<view class="info-row flex-row-sb">
<view class="info-label">换货类型</view>
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
</view>
<!-- 发货时间 - 条件显示 -->
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
<view class="info-label">发货时间</view>
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
</view>
<!-- 完成时间 - 条件显示 -->
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
<view class="info-label">完成时间</view>
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
</view>
```
### 关键设计决策
#### 1. 后端保证的行为
- `GET /api/c/v1/exchange/pending` 仅返回 `shipping` 类型的进行中换货单
- `direct` 类型创建后立即完成,不会出现在待处理列表
- 前端无需额外过滤或判断
#### 2. 向后兼容
- 历史数据没有 `flow_type_name` 时,默认显示"物流换货"
- 新增字段都是条件显示,不影响现有数据展示
- 原有的按钮和表单逻辑保持不变
#### 3. 无需修改的部分
- ✅ API 模块 (`api/modules/exchange.js`) - 自动透传所有响应字段
- ✅ 表单提交逻辑 - 后端负责流程类型校验
- ✅ 按钮显示逻辑 - 因为 direct 类型不会进入待处理状态
### 显示逻辑
| 字段 | 显示条件 | 默认值 |
|------|---------|--------|
| 换货类型 | 始终显示 | "物流换货"(当 `flow_type_name` 为空时) |
| 发货时间 | `shipped_at` 不为空 | 不显示 |
| 完成时间 | `completed_at` 不为空 且 `status === 4` | 不显示 |
### 测试场景
需要测试的场景(待后端接口就绪后验证):
1. **待填写信息** (`status: 1`)
- 显示:换货类型、换货原因、申请时间
- 显示"填写信息"按钮
2. **待发货** (`status: 2`)
- 显示:换货类型、换货原因、申请时间、收件信息
- 不显示发货时间
3. **已发货** (`status: 3`)
- 显示:换货类型、换货原因、申请时间、发货时间、收件信息
- 不显示按钮
4. **已完成** (`status: 4`)
- 显示:换货类型、换货原因、申请时间、发货时间、完成时间、收件信息
- 不显示按钮
5. **无待处理换货单**
- 显示空状态:"暂无换货记录"
### API 响应示例
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 123,
"exchange_no": "EXC20260603001",
"old_asset_type": "iot_card",
"old_identifier": "8986012345678901234",
"exchange_reason": "设备损坏",
"status": 2,
"status_text": "待发货",
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null,
"created_at": "2026-06-03T10:00:00+08:00",
"recipient_name": "张三",
"recipient_phone": "13800138000",
"recipient_address": "北京市朝阳区xxx路xxx号"
}
}
```
### 影响范围
- **修改文件**: 1 个 (`pages/device-exchange/device-exchange.vue`)
- **新增文档**: 3 个proposal, spec, tasks
- **影响用户**: 使用换货功能的客户端用户
- **兼容性**: 完全向后兼容,不影响现有流程
### 后续工作
- [ ] 等待后端接口部署
- [ ] 在测试环境验证所有测试场景
- [ ] 确认不同状态下的显示效果
- [ ] 验证历史数据的兼容性
---
**实施日期**: 2026-06-03
**实施人员**: Claude
**状态**: ✅ 代码实现完成,待测试验证

View File

@@ -1,29 +0,0 @@
## Why
The backend exchange system now supports two flow types: **shipping** (logistics-based exchange requiring address collection and shipment) and **direct** (immediate completion without logistics). The current frontend client only handles the original shipping flow and does not process the new `flow_type`, `flow_type_name`, `shipped_at`, and `completed_at` response fields.
This prevents the frontend from correctly displaying exchange records that may include both flow types and from adapting UI behavior based on flow type constraints.
## What Changes
- Update the exchange pending query and shipping info submission to handle new response fields: `flow_type`, `flow_type_name`, `shipped_at`, `completed_at`
- Preserve existing behavior where the pending exchange API only returns `shipping` type exchanges (status 1/2/3), as `direct` exchanges are completed immediately and never appear in pending state
- Display flow type information in the exchange detail view
- Ensure shipping info submission validates that the exchange is `flow_type = shipping` and `status = 1` (backend enforces this, frontend should handle error gracefully)
- No UI flow changes required since `direct` type exchanges will not appear in the pending list by design
## Capabilities
### New Capabilities
- `exchange-flow-type-display`: Display flow type and completion timestamps in exchange records
### Modified Capabilities
- `exchange-pending-query`: Handle new response fields from `/api/c/v1/exchange/pending`
- `exchange-shipping-submission`: Handle new response fields from `/api/c/v1/exchange/{id}/shipping-info`
## Impact
- Affected code: `pages/device-exchange/device-exchange.vue`, `api/modules/exchange.js`
- Affected API usage: `/api/c/v1/exchange/pending`, `/api/c/v1/exchange/{id}/shipping-info`
- No breaking changes: existing shipping flow behavior remains unchanged
- Backend ensures `direct` type exchanges are not returned by pending query, so frontend maintains current UX

View File

@@ -1,195 +0,0 @@
# Exchange Flow Type Display Specification
## Overview
Update the exchange display to show the new flow type and timestamp fields returned by the backend exchange APIs.
## API Response Changes
### GET /api/c/v1/exchange/pending
**New Response Fields:**
```json
{
"code": 0,
"msg": "success",
"timestamp": "2026-06-03T10:00:00+08:00",
"data": {
"id": 1,
"exchange_no": "EXC20260603001",
"order_no": "EXC20260603001",
"old_asset_type": "iot_card",
"old_identifier": "8986012345678901234",
"exchange_reason": "设备损坏",
"status": 2,
"status_text": "待发货",
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null,
"created_at": "2026-06-03T10:00:00+08:00",
"recipient_name": "张三",
"recipient_phone": "13800138000",
"recipient_address": "北京市朝阳区xxx路xxx号"
}
}
```
**Field Descriptions:**
| Field | Type | Description |
|-------|------|-------------|
| `flow_type` | string | 流程类型: `shipping` (物流换货) or `direct` (直接换货)。历史数据为空时视为 `shipping` |
| `flow_type_name` | string | 流程类型名称: "物流换货" or "直接换货" |
| `shipped_at` | string\|null | 发货时间,仅 `shipping` 流程发货后有值 |
| `completed_at` | string\|null | 换货完成时间,换货完成后有值 |
**Backend Behavior:**
- Only returns exchanges where `flow_type = shipping` AND `status IN (1, 2, 3)`
- `direct` type exchanges are never returned (they complete immediately on creation)
- If no pending exchange exists, returns `data: null`
### POST /api/c/v1/exchange/{id}/shipping-info
**Response includes the same new fields as above**
**Backend Constraints:**
- Only accepts `flow_type = shipping` AND `status = 1`
- Returns 400 error if `flow_type = direct`: `{"code": 400, "message": "流程类型不支持该操作"}`
- Returns 400 error if status is not 1: `{"code": 400, "message": "当前状态不允许填写收货信息"}`
## UI Changes
### Exchange Detail Card
Add flow type display in the exchange info section:
**Before:**
```
换货原因: 设备损坏
申请时间: 2026-06-03 10:00:00
收件人: 张三
联系电话: 13800138000
收货地址: 北京市朝阳区xxx路xxx号
```
**After:**
```
换货类型: 物流换货
换货原因: 设备损坏
申请时间: 2026-06-03 10:00:00
发货时间: 2026-06-04 15:30:00 (仅已发货时显示)
完成时间: 2026-06-05 10:00:00 (仅已完成时显示)
收件人: 张三
联系电话: 13800138000
收货地址: 北京市朝阳区xxx路xxx号
```
**Display Rules:**
1. **换货类型**: Always display `flow_type_name` if present, fallback to "物流换货" if empty
2. **发货时间**: Only display if `shipped_at` is not null
3. **完成时间**: Only display if `completed_at` is not null and `status === 4`
### Error Handling
When submitting shipping info fails with flow type error:
```javascript
// Existing error handling should catch and display backend error message
catch (error) {
console.error('提交换货信息失败', error);
// Backend will return: {"code": 400, "message": "流程类型不支持该操作"}
// Default error toast will show this message
}
```
## Implementation
### 1. Update API Module Types
No changes needed to `api/modules/exchange.js` - it already passes through all response fields.
### 2. Update Exchange Page Template
File: `pages/device-exchange/device-exchange.vue`
Add flow type and timestamp displays:
```vue
<view class="exchange-info">
<view class="info-row flex-row-sb">
<view class="info-label">换货类型</view>
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">换货原因</view>
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">申请时间</view>
<view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
<view class="info-label">发货时间</view>
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
<view class="info-label">完成时间</view>
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
</view>
<!-- Existing recipient fields -->
</view>
```
### 3. No Button Logic Changes
Since `direct` type exchanges never appear in pending (they complete immediately), the existing button visibility logic remains valid:
```vue
<view class="exchange-actions" v-if="exchangeData.status === 1">
<button class="btn-apple btn-primary" @tap="openPopup">填写信息</button>
</view>
```
Backend enforces that only `shipping` + status 1 can submit shipping info, so frontend doesn't need additional flow type checks.
## Testing Scenarios
### 1. Shipping Flow Exchange (Existing Behavior)
- Load pending exchange with `flow_type: "shipping"`, `status: 1`
- Verify "换货类型: 物流换货" displays
- Verify "填写信息" button shows
- Submit shipping info successfully
- Verify response includes new fields
### 2. Shipped Exchange
- Load exchange with `flow_type: "shipping"`, `status: 3`, `shipped_at: "2026-06-04T15:30:00+08:00"`
- Verify "发货时间" row displays
- Verify no action buttons show
### 3. Completed Exchange
- Load exchange with `flow_type: "shipping"`, `status: 4`, `completed_at: "2026-06-05T10:00:00+08:00"`
- Verify "完成时间" row displays
- Verify no action buttons show
### 4. No Pending Exchange
- Call API with no pending exchange
- Verify empty state shows: "暂无换货记录"
### 5. Error Handling (Edge Case)
If somehow a `direct` type exchange appeared and user tried to submit shipping info (should not happen), backend returns 400 error which existing error handler will toast.
## Backward Compatibility
- Historical data without `flow_type` will be treated as `shipping` (per backend spec)
- All new fields are optional in frontend display logic
- Existing shipping submission flow is unchanged

View File

@@ -1,17 +0,0 @@
# Tasks
## 1. Update Exchange Detail Display
- [x] Add flow type display row in exchange info section
- [x] Add shipped_at display row (conditional on value)
- [x] Add completed_at display row (conditional on value and status)
- [x] Ensure backward compatibility for historical data without flow_type
## 2. Testing
- [ ] Test with shipping flow exchange (status 1, 2, 3)
- [ ] Test with completed exchange showing completion time
- [ ] Test with shipped exchange showing ship time
- [ ] Test empty state when no pending exchange
- [ ] Test shipping info submission success with new response fields
## Files Modified
- [x] `pages/device-exchange/device-exchange.vue`: Added new field displays

View File

@@ -1,25 +0,0 @@
## Why
The current login flow decides whether to send the user to the bind-phone page based on `loginData.need_bind_phone`. This makes the phone-binding gate happen only at login time, which is fragile because the actual requirement belongs to entering the homepage, not only to completing login. As a result, the app can enter an inconsistent state when the user reaches `index` through other paths or when the latest bind requirement should be re-evaluated on homepage entry.
## What Changes
- Move the mandatory phone-binding gate out of `pages/login/login.vue`
- Make login success redirect to `pages/index/index` consistently
- Require `pages/index/index.vue` to check whether the current account still needs phone binding every time the page is entered
- Redirect to `pages/bind/bind` from `index` when binding is required, instead of deciding that only during login
- Keep the bind-phone page as the place where the user completes the required binding flow
## Capabilities
### New Capabilities
- `index-phone-bind-gate`: Define homepage-entry binding rules so phone binding is enforced from `index` instead of only from login
### Modified Capabilities
## Impact
- Affected code: `pages/login/login.vue`, `pages/index/index.vue`, `pages/bind/bind.vue`
- Affected flow: post-login routing, homepage entry, mandatory bind-phone redirect
- No backend API changes
- No login credential or bind-phone API contract changes

View File

@@ -1,44 +0,0 @@
## ADDED Requirements
### Requirement: Login success SHALL NOT own the bind-phone routing decision
The system SHALL stop deciding mandatory bind-phone routing inside the login success handler and SHALL route successful login into the homepage entry flow instead.
#### Scenario: Login succeeds for a user who may need binding
- **WHEN** the user completes login successfully
- **THEN** the system SHALL route the user to `pages/index/index`
- **AND** the system SHALL NOT branch directly from the login page to the bind-phone page based only on login-time routing logic
### Requirement: Index entry SHALL enforce the bind-phone gate
The system SHALL determine whether phone binding is required whenever the homepage is entered and SHALL redirect to the bind-phone page if the current account is not yet bound.
#### Scenario: User enters index and binding is required
- **WHEN** the user enters `pages/index/index`
- **AND** the latest account state indicates the phone is not yet bound
- **THEN** the system SHALL redirect the user from `index` to `pages/bind/bind`
#### Scenario: User enters index and binding is not required
- **WHEN** the user enters `pages/index/index`
- **AND** the latest account state indicates the phone is already bound
- **THEN** the system SHALL keep the user on the homepage
#### Scenario: User re-enters index later without binding
- **WHEN** the user enters `pages/index/index` again through a later navigation path
- **AND** the latest account state still indicates the phone is not yet bound
- **THEN** the system SHALL re-apply the bind-phone redirect
- **AND** the redirect SHALL NOT depend on whether the user just came from the login page
### Requirement: Bind completion SHALL remain compatible with homepage entry
The system SHALL preserve the existing bind-phone completion destination to the homepage while allowing the homepage gate to be the source of truth for whether binding is still required.
#### Scenario: User completes binding and returns to index
- **WHEN** the user completes phone binding successfully
- **THEN** the system SHALL return the user to `pages/index/index`
- **AND** entering `index` afterward SHALL no longer redirect if the account is now bound
### Requirement: Index phone-bind gate SHALL NOT change login or bind API contracts
The system SHALL preserve the existing login API, bind-phone API, token handling, and account retrieval contracts while changing only the routing responsibility.
#### Scenario: Routing responsibility changes without API change
- **WHEN** the new homepage-entry gate is applied
- **THEN** the system SHALL change only where the bind requirement is evaluated for routing
- **AND** the system SHALL NOT require backend API contract changes

View File

@@ -1,23 +0,0 @@
## 1. Login Redirect Responsibility
- [x] 1.1 Remove the bind-phone branching decision from the login success handler
- [x] 1.2 Make login success enter `pages/index/index` consistently
## 2. Index Entry Binding Gate
- [x] 2.1 Add a homepage-entry binding check in `pages/index/index.vue`
- [x] 2.2 Re-evaluate whether binding is required every time `index` is entered, not only after login
- [x] 2.3 Redirect from `index` to the bind-phone page when binding is still required
## 3. Bind Flow Compatibility
- [x] 3.1 Keep the bind-phone completion flow returning to `index`
- [x] 3.2 Ensure users who are already bound are not redirected away from `index`
- [x] 3.3 Ensure users who return to `index` without binding are checked again and redirected again if binding is still required
## 4. Regression Verification
- [x] 4.1 Verify login success always routes into `index`
- [x] 4.2 Verify entering `index` without a bound phone redirects to the bind page
- [x] 4.3 Verify entering `index` with a bound phone stays on the homepage
- [x] 4.4 Verify re-entering `index` through later navigation still rechecks binding state

View File

@@ -1,46 +0,0 @@
## Context
The project moved mandatory phone-binding enforcement to homepage entry, and then added a one-time homepage bypass after bind success to avoid an immediate redirect loop. A later draft proposed automatically re-running `POST /api/c/v1/auth/wechat-login` after bind success. The required direction is now simpler than both of those approaches: after mandatory bind success, route the user back to the login page, explain that they need to log in again, preserve the identifier they already entered, and let them manually restart login.
The login page already has behavior that can trigger login automatically from some restored inputs or URL parameters. The new flow therefore needs to preserve the identifier for convenience without accidentally auto-submitting login on the user's behalf.
## Goals / Non-Goals
- Goals:
- Return mandatory bind-success users to the login page
- Show a clear re-login prompt after bind success
- Preserve the previously entered identifier in the login input
- Require the user to manually tap login again
- Non-Goals:
- Do not add a new backend endpoint
- Do not auto-run `POST /api/c/v1/auth/wechat-login` after bind success
- Do not change the ordinary bind flow that starts from homepage actions
## Decisions
- Decision: Mandatory bind completion SHALL route to `pages/login/login` instead of continuing directly to `pages/index/index`.
- Decision: The system SHALL show a one-time prompt that binding succeeded and the user must log in again.
- Decision: The previously entered identifier SHALL be preserved through the bind-success redirect and restored into the login input.
- Decision: Restoring the identifier SHALL NOT auto-submit login or auto-trigger the OAuth flow; the user must explicitly tap login.
## Alternatives Considered
- Keep the one-time homepage bypass: rejected because the post-bind continuation no longer needs to jump directly into the homepage.
- Automatically re-run `POST /api/c/v1/auth/wechat-login`: rejected because the requested flow is to require an explicit user-driven re-login.
- Pass the identifier through a login-page path that auto-starts login: rejected because the user explicitly wants the identifier preserved without automatic login.
## Risks / Trade-offs
- The client must distinguish between restoring an identifier for display and restoring one for automatic login initiation.
- The re-login prompt must be one-time or clearly scoped so it does not keep reappearing on unrelated later visits to the login page.
## Migration Plan
1. Remove the automatic post-bind wechat-login continuation plan from the active draft.
2. Route mandatory bind success back to `pages/login/login`.
3. Preserve the identifier so the login input remains prefilled.
4. Show the re-login prompt and require a manual login tap.
## Open Questions
- Which client-side storage path is the best fit for preserving the identifier while avoiding accidental auto-login behavior on the login page?

View File

@@ -1,30 +0,0 @@
# Change: Return to login for manual re-login after mandatory phone bind
## Why
The current mandatory bind-success continuation either relies on a homepage bypass workaround or on an automatic follow-up login flow. Both approaches make the post-bind path more fragile than necessary. The desired behavior is simpler: after successful mandatory phone binding, return the user to the login page, tell them they need to log in again, preserve the identifier they already entered, and let the user manually tap login.
## What Changes
- Return the user to `pages/login/login` after successful phone binding when the bind page was entered from the mandatory login gate
- Show a one-time prompt on the login page that phone binding succeeded and re-login is required
- Preserve the identifier the user already entered so the login input stays prefilled after returning
- Require the user to manually tap login again instead of auto-starting the login flow
- Keep the existing non-mandatory bind completion behavior unchanged
- Remove dependence on an automatic post-bind wechat-login recheck for this flow
- Preserve the current login and bind-phone API contracts without adding backend endpoints
## Capabilities
### New Capabilities
- `post-bind-manual-relogin`: Define the mandatory post-bind return-to-login and identifier-preservation flow
### Modified Capabilities
- `index-phone-bind-gate`: Allow mandatory bind completion from the login gate to return to login instead of homepage
## Impact
- Affected code: `pages/bind/bind.vue`, `pages/login/login.vue`, `pages/index/index.vue`
- Affected flow: mandatory phone binding, post-bind routing, login input restoration, manual re-login
- No backend API changes
- No login or bind-phone response schema changes

View File

@@ -1,15 +0,0 @@
## MODIFIED Requirements
### Requirement: Bind completion SHALL remain compatible with homepage entry
The system SHALL keep ordinary bind-phone completion compatible with homepage entry while allowing mandatory bind-phone completion from the login gate to return to the login page for a manual re-login.
#### Scenario: User completes ordinary binding and returns to index
- **WHEN** the user completes phone binding successfully after entering the bind page from a later homepage action
- **THEN** the system SHALL return the user to `pages/index/index`
- **AND** entering `index` afterward SHALL no longer redirect if the account is now bound
#### Scenario: User completes mandatory binding and returns to login
- **WHEN** the user completes phone binding successfully after entering the bind page from the mandatory login gate
- **THEN** the system SHALL return the user to `pages/login/login`
- **AND** the system SHALL preserve the previously entered identifier for the login input
- **AND** the user SHALL manually trigger the next login attempt before the homepage-entry gate is evaluated again

View File

@@ -1,37 +0,0 @@
## ADDED Requirements
### Requirement: Mandatory bind completion SHALL return the user to login for manual re-login
The system SHALL return the user to `pages/login/login` after successful phone binding when the bind page was entered from the mandatory login gate, and SHALL require an explicit user-driven re-login before continuing.
#### Scenario: Mandatory bind succeeds and requires manual re-login
- **WHEN** the user enters `pages/bind/bind` from the mandatory bind-phone gate
- **AND** the user completes phone binding successfully
- **THEN** the system SHALL route the user to `pages/login/login` instead of routing directly to `pages/index/index`
- **AND** the system SHALL show a prompt that binding succeeded and re-login is required
- **AND** the system SHALL wait for the user to explicitly trigger login again
### Requirement: Login input SHALL preserve the prior identifier without auto-submitting
The system SHALL preserve the identifier that the user previously entered before the mandatory bind flow and SHALL restore it into the login input without automatically starting login.
#### Scenario: Login page restores identifier after mandatory bind success
- **WHEN** the user is returned to `pages/login/login` after successful mandatory phone binding
- **THEN** the login input SHALL remain populated with the identifier that was entered before the bind flow
- **AND** the system SHALL NOT auto-submit login or auto-trigger the OAuth flow only because the identifier was restored
- **AND** the user SHALL manually tap the login action to continue
### Requirement: Mandatory post-bind return SHALL avoid automatic recheck dependencies
The system SHALL avoid depending on an automatic post-bind wechat-login recheck for the mandatory bind-success continuation flow.
#### Scenario: Mandatory bind success does not auto-call wechat-login
- **WHEN** the user completes phone binding successfully from the mandatory login gate
- **THEN** the system SHALL NOT automatically call `POST /api/c/v1/auth/wechat-login` as the immediate continuation step
- **AND** the post-bind continuation SHALL depend on the user re-entering the login flow manually
### Requirement: Non-mandatory bind completion SHALL keep existing behavior
The system SHALL keep the existing non-mandatory bind completion behavior when the bind page was entered from a later homepage action rather than from the mandatory login gate.
#### Scenario: Ordinary bind completion remains unchanged
- **WHEN** the user enters `pages/bind/bind` from a later homepage action rather than the mandatory login gate
- **AND** the user completes phone binding successfully
- **THEN** the system SHALL keep the current non-mandatory bind completion behavior
- **AND** the system SHALL NOT force the user back to `pages/login/login` for that ordinary bind flow

View File

@@ -1,25 +0,0 @@
## 1. Post-Bind Manual Re-Login
- [x] 1.1 Route mandatory bind success to `pages/login/login` instead of continuing directly to `pages/index/index`
- [x] 1.2 Show a one-time prompt that the user needs to log in again after successful binding
- [x] 1.3 Preserve the previously entered identifier so the login input stays prefilled after returning
- [x] 1.4 Ensure the restored identifier does not auto-trigger login and the user must manually tap login
## 2. Flow Compatibility
- [x] 2.1 Keep the existing non-mandatory bind completion behavior unchanged
- [x] 2.2 Remove dependence on automatic post-bind wechat-login continuation
- [x] 2.3 Keep the normal homepage bind gate behavior for later homepage entries
- [x] 2.4 Remove dependence on a one-time homepage bypass for mandatory bind success
## 3. API Contract Preservation
- [x] 3.1 Keep using the existing login and bind-phone API contracts
- [x] 3.2 Avoid adding a backend endpoint or changing current response schemas
## 4. Regression Verification
- [x] 4.1 Verify mandatory bind success returns to the login page with the identifier still visible
- [x] 4.2 Verify the login page shows the re-login prompt after bind success
- [x] 4.3 Verify login does not auto-start until the user clicks login
- [x] 4.4 Verify ordinary bind entry from homepage actions still follows the current completion path

View File

@@ -1,24 +0,0 @@
## Why
The homepage wallet entry currently displays the raw wallet balance value returned by the backend, which is cent-based. This causes incorrect user-facing values such as showing `100` instead of `1`. In addition, the wallet recharge order list always renders the auto purchase row, which can show misleading fallback text when the backend does not provide auto purchase data.
## What Changes
- Convert the homepage wallet entry balance from cents to yuan before rendering the value inside `钱包(...)`
- Keep the homepage wallet entry aligned with user-facing currency expectations instead of exposing the raw cent amount
- Render the recharge order `自动购包` field only when the recharge order includes auto purchase status data
- Preserve the current auto purchase status text mapping when the field exists, including existing compatibility values
## Capabilities
### New Capabilities
- `wallet-display-rules`: Define wallet-related UI display rules for homepage balance presentation and recharge order field visibility
### Modified Capabilities
## Impact
- Affected code: `components/FunctionCard.vue`, `pages/index/index.vue`, `pages/my-wallet/my-wallet.vue`
- Affected UI flows: homepage function menu wallet entry, wallet recharge order list
- No backend API changes
- No payment flow changes

View File

@@ -1,42 +0,0 @@
## ADDED Requirements
### Requirement: Homepage wallet entry SHALL display wallet balance in yuan
The system SHALL convert the backend wallet balance value from cents to yuan before rendering the balance inside the homepage wallet entry label.
#### Scenario: Cent-based balance is converted for homepage display
- **WHEN** the homepage wallet entry receives a wallet balance value of `100`
- **THEN** the system SHALL display the wallet entry label as `钱包 (1)` instead of showing the raw cent value
#### Scenario: Non-round cent amount is converted for homepage display
- **WHEN** the homepage wallet entry receives a cent-based wallet balance that is not a whole yuan amount
- **THEN** the system SHALL display the converted yuan value
- **AND** the system SHALL NOT display the raw cent integer
#### Scenario: Zero balance is displayed consistently
- **WHEN** the homepage wallet entry receives a wallet balance value of `0`
- **THEN** the system SHALL display `钱包 (0)`
### Requirement: Recharge order auto purchase row SHALL render only when data exists
The system SHALL render the `自动购包` row in the wallet recharge order list only when the recharge order includes auto purchase status data.
#### Scenario: Auto purchase data exists on a recharge order
- **WHEN** a recharge order includes `auto_purchase_status`
- **THEN** the system SHALL render the `自动购包` row
- **AND** the system SHALL display the status text mapped from that value
#### Scenario: Compatible disabled value still counts as existing data
- **WHEN** a recharge order includes `auto_purchase_status` with a value of `0` or `'0'`
- **THEN** the system SHALL render the `自动购包` row
- **AND** the system SHALL display the compatible disabled status text
#### Scenario: Auto purchase data is absent on a recharge order
- **WHEN** a recharge order does not include `auto_purchase_status`
- **THEN** the system SHALL NOT render the `自动购包` row
### Requirement: Wallet display rules SHALL NOT change API contracts or payment behavior
The system SHALL preserve the existing backend data contract, recharge order retrieval flow, payment actions, and wallet detail page amount handling while applying these display rules.
#### Scenario: Display-only adjustment does not alter business flow
- **WHEN** the homepage wallet balance or recharge order list is rendered with the new display rules
- **THEN** the system SHALL only change user-facing presentation
- **AND** the system SHALL NOT change payment processing or backend request behavior

View File

@@ -1,18 +0,0 @@
## 1. Homepage Wallet Balance Display
- [x] 1.1 Add a display rule for the homepage wallet entry that converts the backend cent amount to yuan before rendering
- [x] 1.2 Ensure converted values do not expose the raw cent amount in the `钱包(...)` label
- [x] 1.3 Preserve correct display for zero and non-integer yuan values
## 2. Recharge Order Auto Purchase Field Visibility
- [x] 2.1 Render the recharge order `自动购包` row only when the recharge order includes auto purchase status data
- [x] 2.2 Preserve the existing status text mapping when auto purchase status data exists, including compatible `0` and `1` values
- [x] 2.3 Hide the row entirely when auto purchase status data is absent
## 3. Regression Verification
- [x] 3.1 Verify a homepage wallet balance of `100` is displayed as `1` in the wallet entry
- [x] 3.2 Verify a non-round cent amount is displayed as its yuan value rather than the raw cent integer
- [x] 3.3 Verify recharge orders with `auto_purchase_status` render the `自动购包` row
- [x] 3.4 Verify recharge orders without `auto_purchase_status` do not render the `自动购包` row

View File

@@ -1,31 +0,0 @@
# Project Context
## Purpose
[Describe your project's purpose and goals]
## Tech Stack
- [List your primary technologies]
- [e.g., TypeScript, React, Node.js]
## Project Conventions
### Code Style
[Describe your code style preferences, formatting rules, and naming conventions]
### Architecture Patterns
[Document your architectural decisions and patterns]
### Testing Strategy
[Explain your testing approach and requirements]
### Git Workflow
[Describe your branching strategy and commit conventions]
## Domain Context
[Add domain-specific knowledge that AI assistants need to understand]
## Important Constraints
[List any technical, business, or regulatory constraints]
## External Dependencies
[Document key external services, APIs, or systems]

152
package-lock.json generated
View File

@@ -10,8 +10,6 @@
"@dcloudio/uni-h5": "3.0.0-4080720251210001",
"clipboard": "^2.0.11",
"dayjs": "^1.11.19",
"element-china-area-data": "^6.1.0",
"jweixin-module": "^1.6.0",
"uview-plus": "^3.6.29",
"vue": "^3.4.0"
},
@@ -20,7 +18,6 @@
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
"cross-env": "^10.1.0",
"less": "^4.0.0",
"sass": "^1.63.2",
"vite": "5.2.8"
@@ -66,7 +63,6 @@
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@@ -1918,8 +1914,7 @@
"version": "3.4.19",
"resolved": "https://registry.npmmirror.com/@dcloudio/types/-/types-3.4.19.tgz",
"integrity": "sha512-1foayOFEAQ+jnQLt3ACsovCNjer3/fXn1I2VBpmDOzs2nk/n4UHwRLAxZV/RpxRqaGOPEvKrO/Pq+VI6sAmuRw==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@dcloudio/uni-app": {
"version": "3.0.0-4080720251210001",
@@ -2337,13 +2332,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
@@ -3270,6 +3258,18 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@vitejs/plugin-legacy": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",
@@ -3913,7 +3913,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4060,12 +4059,6 @@
"node": ">=4"
}
},
"node_modules/china-division": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/china-division/-/china-division-2.7.0.tgz",
"integrity": "sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA==",
"license": "MIT"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@@ -4223,39 +4216,6 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/css-font-size-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz",
@@ -4394,15 +4354,6 @@
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"license": "ISC"
},
"node_modules/element-china-area-data": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/element-china-area-data/-/element-china-area-data-6.1.0.tgz",
"integrity": "sha512-IkpcjwQv2A/2AxFiSoaISZ+oMw1rZCPUSOg5sOCwT5jKc96TaawmKZeY81xfxXsO0QbKxU5LLc6AirhG52hUmg==",
"license": "MIT",
"dependencies": {
"china-division": "^2.7.0"
}
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -5097,13 +5048,6 @@
"url": "https://github.com/sponsors/gjtorikian/"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5152,12 +5096,6 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jweixin-module": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/jweixin-module/-/jweixin-module-1.6.0.tgz",
"integrity": "sha512-dGk9cf+ipipHmtzYmKZs5B2toX+p4hLyllGLF6xuC8t+B05oYxd8fYoaRz0T30U2n3RUv8a4iwvjhA+OcYz52w==",
"license": "ISC"
},
"node_modules/lcid": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/lcid/-/lcid-3.1.1.tgz",
@@ -5627,16 +5565,6 @@
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
@@ -5697,9 +5625,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.9",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"funding": [
{
"type": "opencollective",
@@ -5715,7 +5643,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6122,7 +6049,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6225,7 +6151,6 @@
"integrity": "sha512-u56TU0AIFqMtauKl/OJ1AeFsXqRHkgO7nCWmHaDwfxDo9GUMSqBA4NEh6GMuh1CYVM7zuROYtZrHzPc2ixK+ww==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -6414,29 +6339,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
@@ -6614,7 +6516,6 @@
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -6691,7 +6592,8 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -6961,7 +6863,6 @@
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.20.1",
"postcss": "^8.4.38",
@@ -7017,7 +6918,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.4.21.tgz",
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-sfc": "3.4.21",
@@ -7055,22 +6955,6 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/xmlhttprequest": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz",

View File

@@ -1,15 +1,13 @@
{
"scripts": {
"dev:h5": "uni",
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode development",
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production"
"build:h5": "UNI_INPUT_DIR=. uni build -p h5"
},
"devDependencies": {
"@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",
"cross-env": "^10.1.0",
"less": "^4.0.0",
"sass": "^1.63.2",
"vite": "5.2.8"
@@ -18,11 +16,9 @@
"@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",
"element-china-area-data": "^6.1.0",
"jweixin-module": "^1.6.0",
"uview-plus": "^3.6.29",
"vue": "^3.4.0"
"uview-plus": "^3.6.29"
}
}

View File

@@ -60,7 +60,7 @@
{
"path": "pages/device-exchange/device-exchange",
"style": {
"navigationBarTitleText": "换货"
"navigationBarTitleText": "设备换货"
}
},
{
@@ -75,12 +75,6 @@
"navigationBarTitleText": "我的钱包"
}
},
{
"path": "pages/alipay-payment/alipay-payment",
"style": {
"navigationBarTitleText": "支付宝支付"
}
},
{
"path": "pages/error/error",
"style": {
@@ -90,9 +84,9 @@
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "设备卡信息查询",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTitleText": "信息查询",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
}
}

View File

@@ -1,173 +0,0 @@
<template>
<view class="container alipay-page">
<view class="pay-content">
<view class="method-card">
<view class="method-header">
<view class="method-index">方式一</view>
<view class="method-title">浏览器链接支付宝支付</view>
</view>
<view class="browser-tip">点击右上角三个点使用浏览器打开</view>
<image class="tips-image" src="/static/tips.png" mode="widthFix"></image>
</view>
<view class="method-card">
<view class="method-header">
<view class="method-index">方式二</view>
<view class="method-title">支付宝扫码支付</view>
</view>
<view class="qr-wrap">
<u-qrcode v-if="qrUrl" :val="qrUrl" :size="220" unit="px" :show-loading="false"></u-qrcode>
</view>
<view class="qr-desc">截图保存二维码打开支付宝扫一扫即可支付</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getPendingAlipayPaymentData } from '@/utils/payment.js';
const browserUrl = ref('');
const qrUrl = ref('');
const hasRedirected = ref(false);
const isInWechat = () => /micromessenger/i.test(navigator.userAgent);
const decodePaymentUrl = (value) => {
if (!value) {
return '';
}
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - normalized.length % 4) % 4);
return decodeURIComponent(escape(atob(normalized + padding)));
} catch (e) {
return '';
}
};
const getQueryParam = (key) => {
// #ifdef H5
const params = new URLSearchParams(window.location.search);
return params.get(key) || '';
// #endif
// #ifndef H5
return '';
// #endif
};
const getPayUrlFromQuery = () => {
return decodePaymentUrl(getQueryParam('pay64'));
};
const getQrUrlFromQuery = () => {
return decodePaymentUrl(getQueryParam('qr64'));
};
const redirectToAlipayInBrowser = () => {
// #ifdef H5
if (hasRedirected.value || isInWechat() || !browserUrl.value) {
return;
}
hasRedirected.value = true;
window.location.href = browserUrl.value;
// #endif
};
const loadPaymentData = () => {
const data = getPendingAlipayPaymentData();
browserUrl.value = getPayUrlFromQuery() || data?.copyUrl || data?.qrUrl || '';
qrUrl.value = getQrUrlFromQuery() || data?.qrUrl || data?.copyUrl || browserUrl.value;
redirectToAlipayInBrowser();
};
onShow(() => {
loadPaymentData();
});
</script>
<style lang="scss" scoped>
.alipay-page {
min-height: 100vh;
box-sizing: border-box;
padding: 28rpx 24rpx 48rpx;
background: linear-gradient(180deg, #eaf4ff 0%, var(--bg-secondary) 42%);
}
.pay-content {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.method-card {
background: var(--bg-primary);
border-radius: 28rpx;
padding: 32rpx;
box-shadow: 0 16rpx 40rpx rgba(10, 132, 255, 0.08);
border: 1rpx solid rgba(10, 132, 255, 0.08);
}
.qr-desc {
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.55;
color: var(--text-tertiary);
}
.method-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 28rpx;
}
.method-index {
padding: 8rpx 14rpx;
border-radius: 12rpx;
background: var(--primary);
color: var(--text-inverse);
font-size: 22rpx;
font-weight: 700;
}
.method-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-primary);
}
.browser-tip {
padding: 22rpx 24rpx;
border-radius: 18rpx;
background: rgba(10, 132, 255, 0.08);
color: var(--primary);
font-size: 28rpx;
font-weight: 600;
line-height: 1.45;
text-align: center;
}
.tips-image {
display: block;
width: 100%;
margin-top: 28rpx;
border-radius: 18rpx;
background: var(--gray-100);
}
.qr-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 28rpx;
border-radius: 24rpx;
background: #fff;
border: 1rpx dashed rgba(10, 132, 255, 0.24);
}
</style>

View File

@@ -15,14 +15,30 @@
</view>
<view class="package-info">
<view class="info-row flex-row-sb">
<view class="info-label">套餐类型</view>
<view class="info-value">{{ item.package_type === 'formal' ? '普通套餐' : '加油包' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">使用类型</view>
<view class="info-value">{{ item.usage_type === 'single_card' ? '单卡' : '设备' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">激活时间</view>
<view class="info-value">{{ item.activated_at || '-' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">创建时间</view>
<view class="info-value">{{ item.created_at }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">到期时间</view>
<view class="info-value">{{ item.expires_at || '-' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">优先级</view>
<view class="info-value">{{ item.priority }}</view>
</view>
</view>
<view class="divider"></view>
@@ -95,29 +111,50 @@
// 获取已使用流量
const getUsedFlow = (item) => {
if (item.enable_virtual_data) {
// 启用虚流量,显示已用虚流量
return formatMB(item.virtual_used_mb || 0);
} else {
return formatMB(item.real_used_mb || 0);
// 未启用虚流量,显示已用真流量
return formatMB(item.data_usage_mb || 0);
}
};
// 获取总流量
const getTotalFlow = (item) => {
return formatMB(item.real_total_mb || 0);
if (item.enable_virtual_data) {
// 启用虚流量,显示虚流量总量
return formatMB(item.virtual_limit_mb || 0);
} else {
// 未启用虚流量,显示真流量总量
return formatMB(item.data_limit_mb || 0);
}
};
// 获取剩余流量
const getRemainFlow = (item) => {
const total = item.real_total_mb || 0;
const used = item.enable_virtual_data ? (item.virtual_used_mb || 0) : (item.real_used_mb || 0);
const remain = Math.max(total - used, 0);
return formatMB(remain);
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) => {
const used = item.real_used_mb || 0;
const total = item.real_total_mb || 0;
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);

View File

@@ -9,12 +9,11 @@
<view class="card" v-for="item in list" :key="item.iccid">
<view class="flex-row-g20">
<view class="logo">
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
<image :src="getLogo(item.category).logo" mode="aspectFit"></image>
</view>
<view class="flex-col-g20">
<view class="iccid">ICCID: {{ item.iccid }}</view>
<view class="operator">运营商: {{ getCarrier(item.carrier_type).name }}</view>
<view v-if="item.isDevice" class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
<view class="operator">运营商: {{ getLogo(item.category).name }}</view>
</view>
</view>
@@ -39,7 +38,7 @@
<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();
@@ -50,25 +49,15 @@
let currentModalIccid = ref('');
let currentCard = ref(null);
const carrierMap = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
};
let opratorList = reactive([
{ category: '124', logo: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500', name: '中国电信' },
{ category: '125', logo: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500', name: '中国联通' },
{ category: '126', logo: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182', name: '中国移动' }
]);
const carrierLogoMap = {
CMCC: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182',
CUCC: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
CTCC: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500',
CBN: 'https://img1.baidu.com/it/u=3160680953,3401650303&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
};
const getCarrier = (carrierType) => {
return {
name: carrierMap[carrierType] || '-',
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
};
const getLogo = (category) => {
const operator = opratorList.find(item => item.category === category);
return operator || opratorList[0];
};
const loadCards = async () => {
@@ -76,23 +65,26 @@
try {
const assetData = await assetApi.getInfo(userStore.state.identifier);
// 判断是否为设备
if (assetData.asset_type === 'device') {
list.splice(0, list.length, ...(assetData.cards || []).map(card => ({
iccid: card.iccid,
carrier_type: card.carrier_type,
slot_position: card.slot_position,
isDevice: true,
isRealName: card.real_name_status === 1
})));
} else if (assetData.iccid) {
list.splice(0, list.length, {
iccid: assetData.iccid,
carrier_type: assetData.carrier_type,
isDevice: false,
isRealName: assetData.real_name_status === 1
});
// 是设备,调用设备卡列表接口
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 {
list.splice(0, list.length);
// 不是设备(单卡),直接使用 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);
@@ -100,6 +92,14 @@
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) => {
currentCard.value = card;
currentModalIccid.value = card.iccid;
@@ -111,25 +111,22 @@
};
const doRealName = async () => {
const iccid = currentModalIccid.value;
closeModal();
try {
const data = await realnameApi.getLink(userStore.state.identifier, iccid, { showError: false });
const data = await realnameApi.getLink(userStore.state.identifier, currentModalIccid.value);
if (data.realname_url) {
uni.setClipboardData({
data: iccid,
data: currentModalIccid.value,
success: () => {
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
}
});
closeModal();
setTimeout(() => {
window.location.href = data.realname_url;
}, 1500);
}
} catch (e) {
console.error('获取实名链接失败', e);
uni.showToast({ title: e?.msg || '获取实名链接失败', icon: 'none' });
}
};
@@ -247,4 +244,4 @@
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
}
</style>
</style>

View File

@@ -3,7 +3,7 @@
<!-- 自定义导航栏 -->
<view v-if="!fromLogin" class="custom-navbar">
<view class="navbar-back" @tap="goBack">
<image class="back-icon" src="../../static/left.png" mode="aspectFit" />
<text class="back-text">🔙</text>
</view>
<view class="navbar-title">绑定手机号</view>
<view class="navbar-placeholder"></view>
@@ -41,11 +41,6 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { authApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
const userStore = useUserStore();
let bind = reactive({
phone: '',
@@ -65,23 +60,6 @@
fromLogin.value = options.fromLogin === 'true';
});
const prepareManualRelogin = () => {
const preservedIdentifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
userStore.clearUser();
if (preservedIdentifier) {
userStore.setIdentifier(preservedIdentifier);
}
uni.setStorageSync(POST_BIND_RELOGIN_NOTICE_KEY, '1');
// #ifdef H5
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('assetToken');
}
// #endif
};
const getCode = async () => {
if (!bind.phone) {
uni.showToast({ title: '请输入手机号', icon: 'none' });
@@ -122,17 +100,15 @@
try {
await authApi.bindPhone(bind.phone, bind.code);
if (fromLogin.value) {
prepareManualRelogin();
uni.reLaunch({ url: '/pages/login/login' });
return;
}
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);
@@ -153,9 +129,7 @@
};
const goBack = () => {
uni.navigateTo({
url: '/pages/index/index'
})
uni.navigateBack();
};
</script>
@@ -163,10 +137,6 @@
.page {
min-height: 100vh;
background: var(--bg-secondary);
max-width: 750rpx;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.custom-navbar {
@@ -190,8 +160,8 @@
cursor: pointer;
.back-icon {
width: 40rpx;
height: 40rpx;
font-size: 36rpx;
color: var(--text-primary);
}
.back-text {
@@ -217,4 +187,4 @@
.container {
padding: var(--space-md);
}
</style>
</style>

View File

@@ -1,175 +1,83 @@
<template>
<view class="container">
<view v-if="!exchangeData && !loading" class="empty-state">
<view class="empty-icon"></view>
<view class="empty-icon">🔄</view>
<view class="empty-title">暂无换货记录</view>
<view class="empty-desc">当前账号下暂无换货记录</view>
<view class="empty-desc">当前账号下暂无设备换货记录</view>
</view>
<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.flow_type_name || '物流换货' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">换货原因</view>
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
<view 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.shipped_at">
<view class="info-label">发货时间</view>
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
<view class="info-label">完成时间</view>
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.recipient_name">
<view class="info-row flex-row-sb" v-if="exchangeData?.recipient_name">
<view class="info-label">收件人</view>
<view class="info-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>
<up-popup :show="showPopup" mode="center" @close="handlePopupClose">
<up-popup :show="showPopup" mode="center" @close="showPopup=false">
<view class="popup-content">
<view class="popup-title">填写换货信息</view>
<view class="form-item">
<view class="form-label">粘贴收货信息</view>
<up-textarea
v-model="form.raw_shipping_text"
placeholder="可粘贴包含收件人、电话、地址的一整段文字"
autoHeight
:maxlength="-1"
border="surround"
/>
<view class="form-helper">识别结果可继续手动修改</view>
<view class="paste-action-row">
<button class="btn-apple btn-secondary paste-action-btn" @tap="clearPastedText">
清除
</button>
<button class="btn-apple btn-secondary paste-action-btn" @tap="fillFromPastedText">
识别并填充
</button>
</view>
</view>
<view class="form-item">
<view class="form-label">收件人姓名</view>
<up-input
v-model="form.recipient_name"
placeholder="请输入收件人姓名"
border="surround"
/>
<up-input placeholder="请输入收件人姓名" border="surround" v-model="form.recipient_name" />
</view>
<view class="form-item">
<view class="form-label">收件人电话</view>
<up-input
v-model="form.recipient_phone"
placeholder="请输入收件人电话"
border="surround"
/>
<up-input placeholder="请输入收件人电话" border="surround" v-model="form.recipient_phone" />
</view>
<view class="form-item">
<view class="form-label">所在地区</view>
<view class="region-trigger" @tap="openAreaPicker">
<text class="region-trigger-text" :class="{ placeholder: !regionDisplayText }">
{{ regionDisplayText || '请选择省 / 市 / 区' }}
</text>
</view>
<view class="form-label">收货地址</view>
<up-input placeholder="请输入详细收货地址" border="surround" v-model="form.recipient_address" />
</view>
<view class="form-item">
<view class="form-label">详细地址</view>
<up-textarea
v-model="form.recipient_address_detail"
placeholder="请输入详细收货地址"
autoHeight
:maxlength="-1"
border="surround"
/>
</view>
<view class="popup-btn-group">
<button class="btn-apple btn-secondary" @tap="handlePopupClose">取消</button>
<button class="btn-apple btn-secondary" @tap="showPopup=false">取消</button>
<button class="btn-apple btn-primary" @tap="submitExchange">提交</button>
</view>
</view>
</up-popup>
<up-picker
:show="showAreaPicker"
:columns="areaPickerColumns"
keyName="label"
title="选择所在地区"
:defaultIndex="areaPickerDefaultIndex"
closeOnClickOverlay
@confirm="confirmAreaPicker"
@change="handleAreaPickerChange"
@cancel="showAreaPicker = false"
@close="showAreaPicker = false"
/>
</view>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { exchangeApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
buildRegionColumns,
composeRecipientAddress,
getRegionDisplayText,
parseSavedRecipientAddress,
parseShippingText
} from '@/utils/shippingAddress.js';
const PHONE_REG = /^1[3-9]\d{9}$/;
const userStore = useUserStore();
const exchangeData = ref(null);
const loading = ref(false);
const showPopup = ref(false);
const showAreaPicker = ref(false);
const areaPickerColumns = ref([]);
const areaPickerDefaultIndex = ref([0, 0, 0]);
const form = reactive({
raw_shipping_text: '',
let exchangeData = ref(null);
let loading = ref(false);
let showPopup = ref(false);
let form = reactive({
recipient_name: '',
recipient_phone: '',
recipient_region_codes: [],
recipient_address_detail: ''
});
const regionDisplayText = computed(() => {
return getRegionDisplayText(form.recipient_region_codes);
recipient_address: ''
});
const getStatusClass = (status) => {
@@ -185,170 +93,67 @@
const formatTime = (time) => {
if (!time) return '-';
return String(time).split('T').join(' ').slice(0, 19);
};
const syncAreaPickerState = (regionCodes = []) => {
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
areaPickerColumns.value = columns;
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
return time.split('T').join(' ').slice(0, 19);
};
const loadExchange = async () => {
loading.value = true;
try {
const data = await exchangeApi.getPending(userStore.state.identifier);
// 接口返回的是单个对象,不是数组
exchangeData.value = data || null;
} catch (error) {
console.error('加载换货记录失败', error);
} catch (e) {
console.error('加载换货记录失败', e);
exchangeData.value = null;
} finally {
loading.value = false;
}
};
const handlePopupClose = () => {
showPopup.value = false;
showAreaPicker.value = false;
};
const openAreaPicker = () => {
syncAreaPickerState(form.recipient_region_codes);
showAreaPicker.value = true;
loading.value = false;
};
const openPopup = () => {
const currentExchange = exchangeData.value || {};
const { regionCodes, detailAddress } = parseSavedRecipientAddress(
currentExchange.recipient_address || ''
);
form.raw_shipping_text = '';
form.recipient_name = currentExchange.recipient_name || '';
form.recipient_phone = currentExchange.recipient_phone || '';
form.recipient_region_codes = regionCodes;
form.recipient_address_detail = detailAddress;
syncAreaPickerState(regionCodes);
form.recipient_name = exchangeData?.recipient_name || '';
form.recipient_phone = exchangeData?.recipient_phone || '';
form.recipient_address = exchangeData?.recipient_address || '';
showPopup.value = true;
};
const handleAreaPickerChange = (event) => {
const selectedValues = event.value || [];
const selectedProvinceCode = selectedValues[0]?.value || '';
const selectedCityCode =
event.columnIndex === 0 ? '' : selectedValues[1]?.value || '';
const selectedDistrictCode =
event.columnIndex === 2 ? selectedValues[2]?.value || '' : '';
const { columns, defaultIndex } = buildRegionColumns([
selectedProvinceCode,
selectedCityCode,
selectedDistrictCode
]);
areaPickerColumns.value = columns;
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
};
const confirmAreaPicker = ({ value }) => {
const selectedCodes = (value || []).map((item) => item?.value).filter(Boolean);
if (selectedCodes.length === 3) {
form.recipient_region_codes = selectedCodes;
syncAreaPickerState(selectedCodes);
}
showAreaPicker.value = false;
};
const fillFromPastedText = () => {
if (!form.raw_shipping_text.trim()) {
uni.showToast({ title: '请先粘贴收货信息', icon: 'none' });
return;
}
const parsed = parseShippingText(form.raw_shipping_text);
let updatedCount = 0;
if (parsed.recipient_name) {
form.recipient_name = parsed.recipient_name;
updatedCount += 1;
}
if (parsed.recipient_phone) {
form.recipient_phone = parsed.recipient_phone;
updatedCount += 1;
}
if (parsed.regionCodes.length === 3) {
form.recipient_region_codes = parsed.regionCodes;
syncAreaPickerState(parsed.regionCodes);
updatedCount += 1;
}
if (parsed.detailAddress) {
form.recipient_address_detail = parsed.detailAddress;
updatedCount += 1;
}
if (!updatedCount) {
uni.showToast({ title: '未识别到有效收货信息', icon: 'none' });
return;
}
uni.showToast({ title: '已识别并填充', icon: 'success' });
};
const clearPastedText = () => {
form.raw_shipping_text = '';
};
const submitExchange = async () => {
if (!form.recipient_name.trim()) {
if (!form.recipient_name) {
uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
return;
}
if (!form.recipient_phone.trim()) {
if (!form.recipient_phone) {
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
return;
}
if (!PHONE_REG.test(form.recipient_phone.trim())) {
// 手机号格式校验
const phoneReg = /^1[3-9]\d{9}$/;
if (!phoneReg.test(form.recipient_phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
if (form.recipient_region_codes.length !== 3) {
uni.showToast({ title: '请选择省市区', icon: 'none' });
if (!form.recipient_address) {
uni.showToast({ title: '请输入收货地址', icon: 'none' });
return;
}
if (!form.recipient_address_detail.trim()) {
uni.showToast({ title: '请输入详细收货地址', icon: 'none' });
return;
}
const recipientAddress = composeRecipientAddress(
form.recipient_region_codes,
form.recipient_address_detail
);
try {
await exchangeApi.submitShippingInfo(
exchangeData.value.id,
form.recipient_name.trim(),
form.recipient_phone.trim(),
recipientAddress
form.recipient_name,
form.recipient_phone,
form.recipient_address
);
uni.showToast({ title: '提交成功', icon: 'success' });
handlePopupClose();
await loadExchange();
} catch (error) {
console.error('提交换货信息失败', error);
showPopup.value = false;
loadExchange();
} catch (e) {
console.error('提交换货信息失败', e);
}
};
onMounted(() => {
syncAreaPickerState();
loadExchange();
});
</script>
@@ -362,57 +167,24 @@
justify-content: center;
padding: 120rpx 40rpx;
min-height: 60vh;
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.6;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: var(--text-tertiary);
text-align: center;
margin-bottom: 40rpx;
}
.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; margin-bottom: 40rpx; }
.empty-btn { width: 100%; max-width: 400rpx; }
}
.exchange-card {
.exchange-header {
margin-bottom: var(--space-md);
.exchange-no {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.exchange-no { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
}
.exchange-info {
.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);
}
.address-value {
max-width: 400rpx;
text-align: right;
}
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
.info-value { font-size: 24rpx; color: var(--text-primary); }
.address-value { max-width: 400rpx; text-align: right; }
}
}
@@ -420,19 +192,14 @@
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1rpx solid var(--gray-200);
.btn-apple {
width: 100%;
}
.btn-apple { width: 100%; }
}
}
}
.popup-content {
width: 660rpx;
width: 600rpx;
padding: 30rpx;
box-sizing: border-box;
.popup-title {
font-size: 32rpx;
font-weight: 600;
@@ -440,60 +207,19 @@
text-align: center;
margin-bottom: var(--space-lg);
}
.form-item {
margin-bottom: var(--space-md);
.form-label {
font-size: 26rpx;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
}
.form-helper {
margin-top: var(--space-xs);
font-size: 22rpx;
color: var(--text-tertiary);
}
.paste-action-row {
display: flex;
gap: var(--space-md);
margin-top: var(--space-sm);
.paste-action-btn {
flex: 1;
}
}
}
.region-trigger {
display: flex;
align-items: center;
min-height: 84rpx;
padding: 0 24rpx;
border: 2rpx solid var(--border-light);
border-radius: 12rpx;
background: #fff;
.region-trigger-text {
font-size: 28rpx;
color: var(--text-primary);
&.placeholder {
color: var(--text-tertiary);
}
}
}
.popup-btn-group {
display: flex;
gap: var(--space-md);
margin-top: var(--space-lg);
.btn-apple {
flex: 1;
}
.btn-apple { flex: 1; }
}
}
</style>
</style>

View File

@@ -1,15 +1,15 @@
<template>
<view class="container">
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus"
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status" @copy="copy" />
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status" />
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
@authentication="enterDetail('authentication')" />
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
@openDatePicker="openDateRangePicker" /> -->
<TrafficCard :identifier="currentCardNo" />
<TrafficCard :identifier="currentCardNo" @packageLoaded="handlePackageLoaded" />
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
@@ -17,7 +17,7 @@
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi" @copy="copy" />
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" @enter="enterDetail" @sync="onSync">
:isDevice="userInfo.isDevice" @enter="enterDetail" @sync="onSync">
<!-- 修改WIFI弹窗 -->
<up-popup :show="showModifyWifi" mode="center" @close="showModifyWifi=false">
<view class="wifi-popup">
@@ -25,11 +25,11 @@
<view class="flex-col-g20">
<view class="flex-col-g8">
<label class="caption">名称:</label>
<up-input placeholder="WIFI名称" class="wifi-input" v-model="wifi_info.ssid" />
<up-input placeholder="WIFI名称" border="surround" v-model="wifi_info.ssid" />
</view>
<view class="flex-col-g8">
<label class="caption">密码:</label>
<up-input placeholder="WIFI密码" class="wifi-input" v-model="wifi_info.pwd" />
<up-input placeholder="WIFI密码" border="surround" v-model="wifi_info.pwd" />
</view>
</view>
<view class="btn-group mt-20">
@@ -113,7 +113,6 @@
onMounted,
computed
} from 'vue';
import { onShow } from '@dcloudio/uni-app';
import UserInfoCard from '@/components/UserInfoCard.vue';
import DeviceStatusCard from '@/components/DeviceStatusCard.vue';
import VoiceCard from '@/components/VoiceCard.vue';
@@ -148,10 +147,7 @@
connect: 0,
network_status: 1,
status: 1,
packageName: '-',
expireDate: '-',
walletBalance: 0,
iccid: '-',
currentIccid: '-',
category: '-',
phone: '-',
@@ -159,8 +155,7 @@
totalBytesCnt: 0,
ssidName: '-',
ssidPwd: '-',
signal_quality: '暂无数据',
signal_bad_reason: '',
rssi: '-',
onlineStatus: '0',
connCnt: 0,
run_time: 0,
@@ -209,18 +204,6 @@
let showSmsCode = ref(false);
let smsCodePhone = ref('');
let smsCode = ref('');
let indexEntryChecking = ref(false);
const formatDate = (dateStr) => {
if (!dateStr) return '-';
return dateStr.split('T')[0] || '-';
};
const resolveCurrentCard = (cards = []) => {
if (cards.length === 1) return cards[0];
if (cards.length > 1) return cards.find(card => card.is_current) || null;
return null;
};
const loadAssetInfo = async () => {
const identifier = userStore.state.identifier;
@@ -238,13 +221,8 @@
deviceInfo.imei = data.imei || '-';
deviceInfo.asset_type = data.asset_type || 'device';
deviceInfo.bound_phone = data.bound_phone || '';
deviceInfo.packageName = data.current_package || '-';
deviceInfo.expireDate = formatDate(data.current_package_expires_at);
deviceInfo.iccid = data.iccid || '-';
deviceInfo.walletBalance = data.wallet_balance ?? 0;
const currentCard = resolveCurrentCard(data.cards || []);
isRealName.value = !!currentCard?.real_name_at || currentCard?.real_name_status === 1 || data.real_name_status === 1;
realNameStatus.value = isRealName.value ? '已实名' : '未实名';
isRealName.value = data.real_name_status === 1;
realNameStatus.value = data.real_name_status === 1 ? '已实名' : '未实名';
boundPhone.value = data.bound_phone || '';
alreadyBindPhone.value = !!data.bound_phone;
@@ -252,7 +230,9 @@
// 当前卡信息
if (data.cards && data.cards.length > 0) {
deviceInfo.currentIccid = currentCard?.iccid || '-';
// 找到当前卡
const currentCard = data.cards.find(card => card.is_current) || data.cards[0];
deviceInfo.currentIccid = currentCard.iccid || '-';
// 卡列表
deviceInfo.mchList = data.cards.map(card => ({
@@ -265,15 +245,6 @@
deviceInfo.mchList = [];
}
// 运营商信息
const carrierTypeMap = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
};
deviceInfo.carrier_name = (currentCard && carrierTypeMap[currentCard.carrier_type]) || '-';
// 设备实时信息
if (data.device_realtime) {
const rt = data.device_realtime;
@@ -285,8 +256,7 @@
deviceInfo.connCnt = rt.client_number ?? 0;
deviceInfo.max_clients = rt.max_clients ?? 1;
deviceInfo.run_time = rt.run_time || 0;
deviceInfo.signal_quality = rt.signal_quality || '暂无数据';
deviceInfo.signal_bad_reason = rt.signal_bad_reason || '';
deviceInfo.rssi = calculateSignalStrength(rt.rsrp, rt.rsrq, rt.rssi);
} else {
// 没有实时信息时设置默认值
deviceInfo.onlineStatus = '0';
@@ -297,38 +267,27 @@
deviceInfo.connCnt = 0;
deviceInfo.max_clients = 1;
deviceInfo.run_time = 0;
deviceInfo.signal_quality = '暂无数据';
deviceInfo.signal_bad_reason = '';
deviceInfo.rssi = '-';
}
// 到期时间由 TrafficCard 组件获取套餐信息时一并返回
return data;
} catch (e) {
console.error('加载资产信息失败', e);
return null;
} finally {
loading.value = false;
}
loading.value = false;
};
const handleIndexEntry = async () => {
const token = uni.getStorageSync('token');
if (!token) {
uni.reLaunch({ url: '/pages/login/login' });
return;
}
// 计算信号强度
const calculateSignalStrength = (rsrp, rsrq, rssi) => {
// 简单的信号强度计算逻辑
if (!rsrp && !rsrq && !rssi) return '-';
if (indexEntryChecking.value) return;
indexEntryChecking.value = true;
const rsrpNum = parseInt(rsrp);
if (isNaN(rsrpNum)) return '-';
try {
const data = await loadAssetInfo();
if (data && !data.bound_phone) {
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
}
} finally {
indexEntryChecking.value = false;
}
if (rsrpNum >= -80) return '强';
if (rsrpNum >= -95) return '中';
return '弱';
};
// 处理套餐加载完成事件(从 TrafficCard 组件传递过来)
@@ -420,27 +379,15 @@
// #endif
};
const enterBack = async () => {
const enterBack = () => {
if (!deviceInfo.lan_ip || deviceInfo.lan_ip === '-') {
uni.showModal({
title: '提示',
content: '请将设备开机后, 等待2分钟左右进行连接WiFi',
showCancel: false,
buttonText: '我知道了'
uni.showToast({
title: '暂无后台地址',
icon: 'none'
});
return;
}
await new Promise((resolve) => {
uni.showModal({
title: '提示',
content: '请先连接当前设备WiFi否则无法访问后台管理',
cancelText: '取消',
confirmText: '我知道了',
success: (res) => res.confirm && resolve()
});
});
// 跳转到设备后台管理地址
const url = deviceInfo.lan_ip.startsWith('http') ? deviceInfo.lan_ip : `http://${deviceInfo.lan_ip}`;
@@ -646,55 +593,52 @@
return;
}
uni.showModal({
title: '确认同步',
content: '确定要同步运营商数据吗?',
success: (res) => {
if (!res.confirm) return;
uni.showLoading({
title: '同步中...',
mask: true
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
});
assetApi.refresh(identifier).then((data) => {
uni.hideLoading();
if (data.accepted) {
const refreshTypeText = data.refresh_type === 'device' ? '设备' : '卡';
uni.showToast({
title: `${refreshTypeText}数据同步成功`,
icon: 'success',
duration: 2000
});
setTimeout(() => {
loadAssetInfo();
}, 2000);
} else {
uni.showToast({
title: '同步失败,请稍后重试',
icon: 'none'
});
}
}).catch((e) => {
uni.hideLoading();
console.error('同步数据失败', e);
if (e.msg && e.msg.includes('冷却')) {
uni.showToast({
title: e.msg,
icon: 'none',
duration: 2000
});
} else {
uni.showToast({
title: '同步失败',
icon: 'none'
});
}
// 同步成功后,延迟重新加载资产信息
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) => {
@@ -756,7 +700,7 @@
});
break;
case 'recover':
recoverShow.value = true;
restartShow.value = true;
break;
case 'restart':
restartShow.value = true;
@@ -769,32 +713,26 @@
onMounted(() => {
initCurrentMonth();
});
onShow(() => {
handleIndexEntry();
loadAssetInfo();
});
</script>
<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);
}
.wifi-input {
border: 2rpx solid var(--border-light);
border-radius: 8rpx;
padding: 8rpx 16rpx;
margin-bottom: var(--space-5);
}
}
.bottom-spacer {
height: 120rpx;
height: 140rpx;
}
</style>
</style>

View File

@@ -1,138 +1,81 @@
<template>
<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="main-title">登录</view>
<view class="sub-title">请输入标识进行登录</view>
</view>
<view class="form-section">
<view class="input-wrap" :class="{ focus: inputFocus }">
<input
v-model="identifier"
class="input"
placeholder="请输入标识"
placeholder-class="placeholder"
@focus="inputFocus = true"
@blur="handleBlur"
@input="showError = false"
/>
<image
v-if="identifier && inputFocus"
class="clear-icon"
src="/static/clear.png"
mode="aspectFit"
@mousedown.prevent="clearInput"
@touchstart.prevent="clearInput"
></image>
<view 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="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-if="showError" class="error">请输入标识</text>
</view>
<text v-else>登录</text>
</button>
</view>
<view class="btn-section">
<button
class="btn-login"
:class="{ disabled: !identifier || loading || !agreed }"
:disabled="!identifier || loading || !agreed"
@tap="handleLogin"
>
<view v-if="loading" class="loading">
<view class="dot"/>
<view class="dot"/>
<view class="dot"/>
</view>
<text v-else>立即登录</text>
</button>
<button class="btn-scan" @tap="handleScanLogin">
<image src="/static/scan-code.png" mode="aspectFit" class="scan-icon"></image>
<text>扫一扫登录</text>
</button>
</view>
<view class="agreement">
<view class="agreement-text">
<text class="text">登录即表示同意</text>
<text class="link">用户协议</text>
<text class="text"></text>
<text class="link">隐私政策</text>
</view>
<view 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>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { authApi, wechatApi } from '@/api/index.js';
import { authApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js';
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
import { APP_ID, USE_WECHAT_AUTH } from '@/utils/env.js';
const userStore = useUserStore();
const identifier = ref(uni.getStorageSync('identifier') || '');
const identifier = ref('');
const loading = ref(false);
const agreed = ref(true);
const inputFocus = ref(false);
const showError = ref(false);
const wechatAppId = ref('');
const resetLoadingState = () => {
loading.value = false;
};
onMounted(async () => {
const token = uni.getStorageSync('token');
if (token) {
uni.reLaunch({ url: '/pages/index/index' });
return;
}
showPostBindReloginNotice();
handleWechatCallback();
getPathDeviceId();
// 初始化微信 SDK (仅在微信浏览器内执行)
// #ifdef H5
if (isInWechat()) {
try {
await initWxConfig();
console.log('微信 SDK 初始化成功');
} catch (e) {
console.error('微信 SDK 初始化失败', e);
}
}
// #endif
});
onShow(() => {
resetLoadingState();
});
onHide(() => {
resetLoadingState();
});
onUnload(() => {
resetLoadingState();
});
const showPostBindReloginNotice = () => {
if (uni.getStorageSync(POST_BIND_RELOGIN_NOTICE_KEY) !== '1') {
return;
}
uni.removeStorageSync(POST_BIND_RELOGIN_NOTICE_KEY);
uni.showToast({
title: '绑定手机号成功,请重新登录',
icon: 'none',
duration: 2500
});
};
const getCode = () => {
const params = new URLSearchParams(window.location.search);
@@ -143,41 +86,13 @@
window.history.replaceState({}, '', window.location.pathname);
};
const getWechatAppId = async () => {
if (wechatAppId.value) {
return wechatAppId.value;
}
const data = await wechatApi.getAppId();
if (!data || !data.app_id) {
uni.showToast({ title: '微信公众号配置缺失', icon: 'none' });
throw new Error('wechat app_id is empty');
}
wechatAppId.value = data.app_id;
return wechatAppId.value;
};
const redirectToWxAuth = async (assetToken) => {
const redirectToWxAuth = (assetToken) => {
sessionStorage.setItem('assetToken', assetToken);
const appId = await getWechatAppId();
const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname);
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`;
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 clearInput = () => {
identifier.value = '';
showError.value = false;
inputFocus.value = true;
};
const handleBlur = () => {
setTimeout(() => {
inputFocus.value = false;
}, 200);
};
const handleLogin = () => {
if (!identifier.value) {
showError.value = true;
@@ -186,63 +101,6 @@
doLogin();
};
const parseScanIdentifier = (resultStr) => {
const scanValue = (resultStr || '').trim();
if (!scanValue) return '';
const barcodeMatch = scanValue.match(/^[A-Z0-9_]+,(.+)$/);
if (barcodeMatch) return barcodeMatch[1].trim();
if (scanValue.includes('?')) {
const params = new URLSearchParams(scanValue.split('?')[1]);
const queryValue = params.get('cardNum') || params.get('identifier');
if (queryValue) return queryValue;
}
const match = scanValue.match(/=([^=]+)$/);
return match ? match[1].trim() : scanValue;
};
const handleScanLogin = async () => {
// #ifdef H5
try {
// 检查是否在微信环境
if (!isInWechat()) {
uni.showToast({ title: '请在微信中打开', icon: 'none' });
return;
}
// 调用扫一扫
const resultStr = await wxScan();
console.log('扫码结果:', resultStr);
const value = parseScanIdentifier(resultStr);
if (value) {
identifier.value = value;
handleLogin();
} else {
uni.showToast({ title: '无效的扫码内容', icon: 'none' });
}
} catch (err) {
console.error('扫码错误:', err);
// uni.showToast({ title: JSON.stringify(err), icon: 'none' });
}
// #endif
// #ifndef H5
uni.showToast({ title: '请在微信中打开', icon: 'none' });
// #endif
};
const getPathDeviceId = () => {
const params = new URLSearchParams(window.location.search);
const idf = params.get('identifier');
if (idf) {
identifier.value = idf;
handleLogin();
}
};
const doLogin = async () => {
loading.value = true;
try {
@@ -250,7 +108,21 @@
userStore.setAssetToken(verifyData.asset_token);
userStore.setIdentifier(identifier.value);
await redirectToWxAuth(verifyData.asset_token);
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) {
console.error('登录失败', e);
loading.value = false;
@@ -258,6 +130,8 @@
};
const handleWechatCallback = async () => {
if (!USE_WECHAT_AUTH) return;
const code = getCode();
const assetToken = sessionStorage.getItem('assetToken');
@@ -271,18 +145,44 @@
userStore.setToken(loginData.token);
uni.setStorageSync('identifier', userStore.state.identifier);
// H5 环境获取微信用户信息
// #ifdef H5
if (loginData.user_info) {
userStore.setUserInfo({
avatar: loginData.user_info.avatar || '',
nickname: loginData.user_info.nickname || ''
});
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) {
@@ -290,172 +190,343 @@
loading.value = false;
}
};
onMounted(() => {
handleWechatCallback();
});
</script>
<style lang="scss" scoped>
.login-page {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: linear-gradient(135deg, #005cbf 0%, #007aff 50%, #4da6ff 100%);
min-height: 100vh;
background: var(--bg-secondary);
max-width: 750rpx;
margin: 0 auto;
width: 100%;
padding: 40rpx;
box-sizing: border-box;
}
.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;
}
50% {
transform: translate(100rpx, -80rpx) scale(1.2);
opacity: 0.5;
}
}
@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 {
width: 100%;
}
.content {
background: var(--bg-primary);
border-radius: var(--radius-large);
position: relative;
padding: 60rpx 40rpx;
box-shadow: var(--shadow-medium);
z-index: 1;
}
.header {
text-align: center;
margin-bottom: 60rpx;
margin-bottom: 80rpx;
}
.main-title {
font-size: 48rpx;
font-size: 56rpx;
font-weight: 700;
color: var(--text-primary);
color: #FFFFFF;
line-height: 1.3;
margin-bottom: 16rpx;
letter-spacing: -0.5rpx;
margin-bottom: 24rpx;
letter-spacing: 0.5rpx;
text-shadow: 0 2rpx 8rpx rgba(64, 40, 128, 0.3);
}
.sub-title {
font-size: 26rpx;
color: var(--text-tertiary);
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: 48rpx;
margin-bottom: 60rpx;
}
.input-wrap {
display: flex;
align-items: center;
gap: 16rpx;
background: var(--bg-secondary);
border: 2rpx solid var(--border-color);
border-radius: var(--radius-medium);
padding: 28rpx 32rpx;
transition: all 0.3s ease;
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: var(--bg-primary);
border-color: var(--primary);
box-shadow: 0 0 0 4rpx rgba(10, 132, 255, 0.1);
background: rgba(255, 255, 255, 1);
box-shadow: 0 4rpx 20rpx rgba(64, 40, 128, 0.2);
}
.input {
flex: 1;
font-size: 30rpx;
color: var(--text-primary);
font-size: 32rpx;
color: #1A1A1A;
background: transparent;
}
.placeholder {
color: var(--text-quaternary);
}
.clear-icon {
width: 32rpx;
height: 32rpx;
opacity: 0.4;
transition: opacity 0.3s ease;
cursor: pointer;
flex-shrink: 0;
}
.clear-icon:active {
opacity: 0.6;
color: #C4C4C4;
}
.error {
font-size: 24rpx;
color: var(--danger);
color: #EF4444;
margin-top: 16rpx;
padding-left: 8rpx;
}
.btn-section {
margin-bottom: 48rpx;
margin-bottom: 60rpx;
}
.btn-login {
width: 100%;
height: 96rpx;
background: var(--primary);
border: none;
border-radius: var(--radius-medium);
font-size: 32rpx;
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: var(--text-inverse);
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4rpx 12rpx rgba(10, 132, 255, 0.2);
transition: all 0.3s;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.btn-login.disabled {
background: var(--gray-300);
color: var(--text-quaternary);
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) {
transform: translateY(2rpx);
box-shadow: 0 2rpx 8rpx rgba(10, 132, 255, 0.2);
}
.btn-scan {
width: 100%;
height: 88rpx;
background: var(--bg-primary);
border: 2rpx solid var(--primary);
border-radius: var(--radius-medium);
font-size: 30rpx;
font-weight: 500;
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 24rpx;
transition: all 0.3s ease;
}
.btn-scan:active {
background: rgba(10, 132, 255, 0.05);
}
.scan-icon {
width: 40rpx;
height: 40rpx;
opacity: 0.8;
}
.loading {
display: flex;
align-items: center;
gap: 12rpx;
gap: 8rpx;
}
.dot {
width: 12rpx;
height: 12rpx;
background: var(--text-inverse);
width: 10rpx;
height: 10rpx;
background: #FFFFFF;
border-radius: 50%;
animation: load 1.4s infinite ease-in-out both;
}
@@ -492,13 +563,14 @@
}
.text {
color: var(--text-tertiary);
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.link {
color: var(--primary);
color: #FFFFFF;
font-weight: 500;
white-space: nowrap;
text-decoration: underline;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +1,112 @@
<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)"
>
<view v-for="(item, index) in filterOptions" :key="index" class="tab-item"
:class="{ active: filterIndex === index }" @tap="onFilterChange(index)">
{{ item.label }}
<view v-if="filterIndex === index" class="tab-line"></view>
</view>
</view>
<scroll-view scroll-y class="scroll-container" @scrolltolower="loadMore">
<view v-if="orderList.length === 0 && !loading" class="empty-state">
<view class="empty-icon">ORD</view>
<view class="empty-title">暂无订单</view>
<view class="empty-desc">当前账号下暂无订单信息</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>
<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="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 v-else class="order-list">
<view class="order-card" v-for="item in orderList" :key="item.order_id">
<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="card-body">
<view class="info-grid">
<view class="info-item">
<view class="info-label">套餐名称</view>
<view class="info-value">
<view class="package-item" v-for="(pkgName, pkgIndex) in item.package_names" :key="pkgIndex">
<text class="package-name">{{ pkgName }}</text>
</view>
</view>
</view>
<view class="info-item">
<view class="info-label">订单金额</view>
<view class="info-value amount">¥{{ formatMoney(item.total_amount) }}</view>
</view>
<view class="info-item">
<view class="info-label">下单时间</view>
<view class="info-value">{{ item.created_at }}</view>
</view>
</view>
<view v-if="item.payment_status === 1" class="card-footer">
<button class="btn-pay" :disabled="orderPayingId !== null" @tap="showOrderPaymentMethods(item)">
{{ isOrderPaying(item) ? '处理中...' : '立即支付' }}
</button>
</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>上拉加载更多</text>
</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>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { assetApi, orderApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
consumePendingPaymentRefresh,
handlePaymentError,
isValidAlipayPaymentLink,
isValidWechatPayConfig,
openAlipayPayment,
PAYMENT_REFRESH_TARGETS,
showPaymentToast,
wechatH5Pay
} from '@/utils/payment.js';
ref,
reactive,
onMounted
} from 'vue';
import {
orderApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
const userStore = useUserStore();
const orderList = reactive([]);
const loading = ref(false);
const noMore = ref(false);
const page = ref(1);
const orderPayingId = ref(null);
let orderList = reactive([]);
let loading = ref(false);
let noMore = ref(false);
let page = ref(1);
const pageSize = 10;
const filterIndex = ref(0);
const isDeviceAsset = ref(true);
let assetTypePromise = null;
const paymentMethodOptions = computed(() => [
isDeviceAsset.value
? { label: '微信支付', value: 'wechat' }
: { label: '支付宝支付', value: 'alipay' },
{ label: '钱包支付', value: 'wallet' }
]);
const filterOptions = [
{ label: '全部', value: null },
{ label: '待支付', value: 1 },
{ label: '已支付', value: 2 },
{ label: '已取消', value: 3 },
{ label: '已退款', value: 4 }
let filterIndex = ref(0);
let filterOptions = [{
label: '全部',
value: null
},
{
label: '待支付',
value: 1
},
{
label: '已支付',
value: 2
},
{
label: '已取消',
value: 3
},
{
label: '已退款',
value: 4
}
];
const formatMoney = (amount) => {
@@ -128,53 +124,17 @@
return classMap[status] || '';
};
const loadAssetType = async () => {
if (!userStore.state.identifier) return;
if (assetTypePromise) return assetTypePromise;
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
.then((data) => {
isDeviceAsset.value = data.asset_type === 'device';
})
.catch((error) => {
console.error('加载资产类型失败', error);
})
.finally(() => {
assetTypePromise = null;
});
return assetTypePromise;
};
const resetOrderListAndLoad = () => {
const onFilterChange = (index) => {
filterIndex.value = index;
page.value = 1;
noMore.value = false;
loadOrderList();
};
const scheduleOrderStatusRefresh = () => {
uni.showToast({
title: '正在同步支付状态',
icon: 'none'
});
resetOrderListAndLoad();
setTimeout(() => {
resetOrderListAndLoad();
}, 1500);
setTimeout(() => {
resetOrderListAndLoad();
}, 3000);
};
const onFilterChange = (index) => {
filterIndex.value = index;
resetOrderListAndLoad();
};
const loadOrderList = async (append = false) => {
if (loading.value || (append && noMore.value)) return;
if (loading.value || noMore.value) return;
loading.value = true;
const paymentStatus = filterOptions[filterIndex.value].value;
try {
@@ -185,17 +145,11 @@
paymentStatus
);
const newData = (data.items || []).map((item) => ({
const newData = (data.items || []).map(item => ({
...item,
payment_status_name: item.payment_status === 1
? '待支付'
: item.payment_status === 2
? '已支付'
: item.payment_status === 3
? '已取消'
: item.payment_status === 4
? '已退款'
: '未知',
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) : ''
}));
@@ -208,12 +162,11 @@
if (newData.length < pageSize) {
noMore.value = true;
} else {
page.value += 1;
page.value++;
}
} catch (error) {
console.error('加载订单列表失败', error);
} catch (e) {
console.error('加载订单列表失败', e);
}
loading.value = false;
};
@@ -223,126 +176,131 @@
}
};
const isOrderPaying = (order) => orderPayingId.value === order.order_id;
const showOrderPaymentMethods = async (order) => {
if (orderPayingId.value !== null || !order?.order_id) return;
await loadAssetType();
const options = paymentMethodOptions.value;
uni.showActionSheet({
itemList: options.map((item) => item.label),
success: ({ tapIndex }) => {
const selectedMethod = options[tapIndex]?.value;
if (selectedMethod) {
handleOrderPayment(order, selectedMethod);
}
}
});
};
const handleOrderPayment = async (order, paymentMethod) => {
if (orderPayingId.value !== null || !order?.order_id) return;
orderPayingId.value = order.order_id;
const handleOrderPayment = async (order) => {
uni.showLoading({
title: '处理中...',
mask: true
});
try {
const payData = await orderApi.pay(order.order_id, paymentMethod);
const payData = await orderApi.pay(order.order_id, 'wechat');
uni.hideLoading();
if (paymentMethod === 'wallet') {
showPaymentToast(true, '支付成功');
setTimeout(() => {
resetOrderListAndLoad();
}, 1500);
return;
}
if (paymentMethod === 'wechat') {
if (!isValidWechatPayConfig(payData?.pay_config)) {
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
return;
}
try {
await wechatH5Pay(payData.pay_config);
showPaymentToast(true, '支付成功');
setTimeout(() => {
resetOrderListAndLoad();
}, 1500);
} catch (error) {
handlePaymentError(error);
}
return;
}
if (!isValidAlipayPaymentLink(payData?.payment_link)) {
if (!payData.pay_config || !payData.pay_config.package) {
uni.showToast({
title: '支付链接获取失败',
title: '支付参数获取失败',
icon: 'none'
});
return;
}
await openAlipayPayment(payData.payment_link, PAYMENT_REFRESH_TARGETS.ORDER_LIST);
} catch (error) {
// #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('订单支付失败', error);
console.error('支付失败', e);
const errorMsg = e.msg || e.message || '支付失败,请稍后重试';
uni.showToast({
title: error.msg || error.message || '支付失败,请稍后重试',
title: errorMsg,
icon: 'none'
});
} finally {
orderPayingId.value = null;
}
};
onShow(() => {
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.ORDER_LIST)) {
return;
}
scheduleOrderStatusRefresh();
});
onMounted(() => {
loadAssetType();
loadOrderList();
});
</script>
<style lang="scss" scoped>
.container {
position: fixed;
top: var(--window-top);
left: 0;
right: 0;
bottom: 0;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
max-width: 750rpx;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
padding-top: 0;
background: #f5f6f7;
}
.filter-tabs {
display: flex;
background: #fff;
border-radius: 20rpx;
z-index: 100;
flex-shrink: 0;
margin-top: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
border-radius: 10rpx;
z-index: 10;
.tab-item {
position: relative;
@@ -372,9 +330,8 @@
}
}
.scroll-container {
.order-scroll {
flex: 1;
height: 100%;
}
.empty-state {
@@ -385,10 +342,9 @@
padding: 200rpx 40rpx;
.empty-icon {
font-size: 56rpx;
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.6;
letter-spacing: 4rpx;
}
.empty-title {
@@ -445,6 +401,12 @@
color: #333;
font-family: monospace;
}
.order-id {
font-size: 24rpx;
color: #666;
font-family: monospace;
}
}
.card-body {
@@ -475,15 +437,32 @@
}
}
.package-item {
display: flex;
align-items: center;
padding: 8rpx 0;
.divider {
height: 1rpx;
background: #f5f5f5;
margin: 24rpx 0;
}
.package-name {
font-size: 26rpx;
color: #333;
.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;
}
}
}
}
.card-footer {
@@ -501,11 +480,7 @@
padding: 16rpx 40rpx;
font-size: 26rpx;
font-weight: 500;
width: 100%;
&[disabled] {
opacity: 0.7;
}
width:100%;
&::after {
border: none;
@@ -548,4 +523,4 @@
color: #999;
font-size: 24rpx;
}
</style>
</style>

View File

@@ -1,45 +1,37 @@
<template>
<view class="container">
<view v-if="packageList.length === 0 && !loading" class="empty-state">
<view class="empty-icon">PKG</view>
<view class="empty-title">暂无可购套餐请联系客服</view>
<view class="empty-icon">📦</view>
<view class="empty-title">暂无可购套餐</view>
<view class="empty-desc">当前资产暂无适用的套餐</view>
</view>
<view class="card package-card" v-for="item in packageList" :key="item.package_id">
<view class="package-header">
<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-main">
<view class="data-block">
<view class="data-label">套餐流量</view>
<view class="package-data">{{ formatData(item.data_allowance, item.data_unit) }}</view>
</view>
<view class="validity-box">
<view class="validity-value">{{ item.validity_days }}</view>
<view class="validity-label">天有效期</view>
</view>
</view>
<view class="package-desc" v-if="item.description">{{ item.description }}</view>
<view class="package-footer">
<view class="price-block">
<text class="price-symbol">¥</text>
<text class="package-price">{{ formatMoney(item.retail_price) }}</text>
</view>
<view class="package-data">{{ formatData(item.data_allowance, item.data_unit) }}</view>
<view class="package-desc">{{ item.description }}</view>
<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-popup :show="showModal" mode="center" @close="showModal = false">
<!-- 支付方式选择弹窗 -->
<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 class="popup-close" @tap="showModal=false">×</view>
</view>
<view class="package-summary">
@@ -53,13 +45,13 @@
<view class="detail-divider"></view>
<view class="detail-item">
<text class="detail-label">有效期</text>
<text class="detail-value">{{ currentPackage?.validity_days }} </text>
<text class="detail-value">{{ currentPackage?.validity_days }}</text>
</view>
</view>
</view>
<view class="payment-methods">
<view v-if="isDeviceAsset" class="method-item" :class="{ active: paymentMethod === 'wechat' }" @tap="selectPaymentMethod('wechat')">
<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>
@@ -67,14 +59,6 @@
<view class="method-radio" :class="{ checked: paymentMethod === 'wechat' }"></view>
</view>
<view v-else class="method-item" :class="{ active: paymentMethod === 'alipay' }" @tap="selectPaymentMethod('alipay')">
<view class="method-left">
<view class="method-icon method-badge method-badge-alipay"></view>
<text class="method-name">支付宝支付</text>
</view>
<view class="method-radio" :class="{ checked: paymentMethod === 'alipay' }"></view>
</view>
<view class="method-item" :class="{ active: paymentMethod === 'wallet' }" @tap="selectPaymentMethod('wallet')">
<view class="method-left">
<image class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
@@ -85,10 +69,8 @@
</view>
<view class="popup-footer">
<button class="btn-apple btn-secondary" @tap="showModal = false">取消</button>
<button class="btn-apple btn-primary" :disabled="paySubmitting" @tap="confirmPay">
{{ paySubmitting ? '处理中...' : '确认支付' }}
</button>
<button class="btn-apple btn-secondary" @tap="showModal=false">取消</button>
<button class="btn-apple btn-primary" @tap="confirmPay">确认支付</button>
</view>
</view>
</up-popup>
@@ -97,31 +79,17 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { assetApi, orderApi, walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
consumePendingPaymentRefresh,
handlePaymentError,
isValidAlipayPaymentLink,
isValidWechatPayConfig,
openAlipayPayment,
PAYMENT_REFRESH_TARGETS,
showPaymentToast,
wechatH5Pay
} from '@/utils/payment.js';
const userStore = useUserStore();
const showModal = ref(false);
const currentPackage = ref(null);
const packageList = reactive([]);
const loading = ref(false);
const paymentMethod = ref('wechat');
const walletBalance = ref(0);
const paySubmitting = ref(false);
const isDeviceAsset = ref(true);
let assetTypePromise = null;
let showModal = ref(false);
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';
@@ -130,43 +98,9 @@
const formatData = (allowance, unit) => {
if (unit === 'MB') {
return allowance >= 1024 ? `${(allowance / 1024).toFixed(0)} GB` : `${allowance} MB`;
return allowance >= 1024 ? (allowance / 1024).toFixed(0) + ' GB' : allowance + ' MB';
}
return `${allowance} ${unit}`;
};
const getDefaultPaymentMethod = () => isDeviceAsset.value ? 'wechat' : 'alipay';
const isPaymentMethodAvailable = (method) => {
if (method === 'wallet') return true;
if (method === 'wechat') return isDeviceAsset.value;
if (method === 'alipay') return !isDeviceAsset.value;
return false;
};
const normalizePaymentMethod = () => {
if (!isPaymentMethodAvailable(paymentMethod.value)) {
paymentMethod.value = getDefaultPaymentMethod();
}
};
const loadAssetType = async () => {
if (!userStore.state.identifier) return;
if (assetTypePromise) return assetTypePromise;
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
.then((data) => {
isDeviceAsset.value = data.asset_type === 'device';
normalizePaymentMethod();
})
.catch((error) => {
console.error('加载资产类型失败', error);
})
.finally(() => {
assetTypePromise = null;
});
return assetTypePromise;
return allowance + ' ' + unit;
};
const loadPackages = async () => {
@@ -174,8 +108,8 @@
try {
const data = await assetApi.getPackages(userStore.state.identifier);
packageList.splice(0, packageList.length, ...(data.packages || []));
} catch (error) {
console.error('加载套餐列表失败', error);
} catch (e) {
console.error('加载套餐列表失败', e);
}
loading.value = false;
};
@@ -184,91 +118,40 @@
try {
const data = await walletApi.getDetail(userStore.state.identifier);
walletBalance.value = data.balance || 0;
} catch (error) {
console.error('加载钱包余额失败', error);
} catch (e) {
console.error('加载钱包余额失败', e);
}
};
const syncPackagePageState = () => {
loadAssetType();
loadPackages();
loadWalletBalance();
};
const buyPackage = async (item) => {
await loadAssetType();
const buyPackage = (item) => {
currentPackage.value = item;
paymentMethod.value = getDefaultPaymentMethod();
paymentMethod.value = 'wechat';
showModal.value = true;
};
const selectPaymentMethod = (method) => {
if (!isPaymentMethodAvailable(method)) return;
paymentMethod.value = method;
};
const hasPreparedPaymentData = (paymentData) => {
return isValidWechatPayConfig(paymentData?.pay_config) ||
isValidAlipayPaymentLink(paymentData?.payment_link);
};
const handleWechatPay = async (payConfig, isForceRecharge = false) => {
try {
await wechatH5Pay(payConfig);
showPaymentToast(true, isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功');
setTimeout(() => {
loadWalletBalance();
}, 1500);
} catch (error) {
handlePaymentError(error);
}
};
const handlePreparedPayment = async (paymentData, isForceRecharge = false) => {
if (isValidWechatPayConfig(paymentData?.pay_config)) {
await handleWechatPay(paymentData.pay_config, isForceRecharge);
return;
}
if (isValidAlipayPaymentLink(paymentData?.payment_link)) {
await openAlipayPayment(paymentData.payment_link, PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER);
return;
}
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
};
const getCreateOrderPaymentMethod = () => {
if (paymentMethod.value === 'alipay') return 'alipay';
if (paymentMethod.value === 'wallet' && !isDeviceAsset.value) return 'alipay';
return undefined;
};
const confirmPay = async () => {
if (paySubmitting.value || !currentPackage.value) return;
if (!currentPackage.value) return;
paySubmitting.value = true;
let releaseInModalCallback = false;
uni.showLoading({
title: '创建订单...',
mask: true
});
uni.showLoading({ title: '创建订单...', mask: true });
try {
// 第一步:创建订单
const orderResult = await orderApi.create(
userStore.state.identifier,
[currentPackage.value.package_id],
getCreateOrderPaymentMethod()
[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({
@@ -279,7 +162,9 @@
return;
}
if (recharge.status === 1 && !hasPreparedPaymentData(orderResult)) {
// 检查充值订单是否已存在(待支付状态但无支付配置)
if (recharge.status === 1 && (!orderResult.pay_config || !orderResult.pay_config.package)) {
// 待支付订单已存在,引导用户跳转到订单页面
uni.hideLoading();
showModal.value = false;
uni.showModal({
@@ -296,83 +181,81 @@
return;
}
if (!hasPreparedPaymentData(orderResult)) {
// 检查是否有支付配置(新订单应该有)
if (!orderResult.pay_config || !orderResult.pay_config.package) {
uni.hideLoading();
showModal.value = false;
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
return;
}
// 获取强充信息
const forceRechargeAmount = orderResult.linked_package_info?.force_recharge_amount || recharge.amount;
const packageNames = orderResult.linked_package_info?.package_names || [];
const packageNameStr = packageNames.length > 0 ? packageNames.join('、') : '套餐';
uni.hideLoading();
showModal.value = false;
releaseInModalCallback = true;
// 提示用户需要强充
const packageNameStr = packageNames.length > 0 ? packageNames.join('、') : '套餐';
const message = `购买${packageNameStr}需要先充值 ¥${formatMoney(forceRechargeAmount)},充值成功后将自动购买套餐`;
uni.showModal({
title: '需要充值',
content: `购买${packageNameStr}需要先充值 ¥${formatMoney(forceRechargeAmount)},充值成功后将自动购买套餐`,
content: message,
confirmText: '去充值',
cancelText: '取消',
success: async (res) => {
try {
if (res.confirm) {
await handlePreparedPayment(orderResult, true);
}
} finally {
paySubmitting.value = false;
if (res.confirm) {
// 调起微信支付
await handleWechatPay(orderResult.pay_config, true);
}
},
fail: () => {
paySubmitting.value = false;
}
});
return;
}
if (!orderResult?.order?.order_id) {
// 检查是否有订单信息(非强充订单)
if (!orderResult || !orderResult.order) {
uni.hideLoading();
showModal.value = false;
uni.showToast({
title: '订单创建失败',
icon: 'none'
});
uni.showToast({ title: '订单创建失败', icon: 'none' });
return;
}
uni.showLoading({
title: '准备支付...',
mask: true
});
const orderId = orderResult.order.order_id;
const payResult = await orderApi.pay(orderResult.order.order_id, paymentMethod.value);
// 第二步:获取支付参数
uni.showLoading({ title: '准备支付...', mask: true });
const payResult = await orderApi.pay(orderId, paymentMethod.value);
uni.hideLoading();
showModal.value = false;
if (paymentMethod.value === 'wallet') {
uni.showToast({
title: '支付成功',
icon: 'success'
});
// 处理不同支付方式
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);
return;
}
await handlePreparedPayment(payResult);
} catch (error) {
} catch (e) {
uni.hideLoading();
showModal.value = false;
console.error('创建订单或支付失败', error);
console.error('创建订单或支付失败', e);
if (error.code === 1008) {
// 特殊错误处理:订单正在创建中
if (e.code === 1008) {
uni.showModal({
title: '提示',
content: '订单正在创建中,是否跳转到我的订单页面进行支付?',
@@ -386,38 +269,75 @@
return;
}
uni.showToast({
title: error.msg || error.message || '操作失败,请稍后重试',
icon: 'none'
});
} finally {
if (!releaseInModalCallback) {
paySubmitting.value = false;
}
// 其他错误提示
const errorMsg = e.msg || e.message || '操作失败,请稍后重试';
uni.showToast({ title: errorMsg, icon: 'none' });
}
};
onShow(() => {
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER)) {
const handleWechatPay = async (payConfig, isForceRecharge = false) => {
// 验证参数
if (!payConfig.package || !payConfig.package.startsWith('prepay_id=')) {
uni.showToast({ title: '支付参数无效', icon: 'none' });
return;
}
syncPackagePageState();
uni.showModal({
title: '支付状态确认',
content: '已返回页面,支付结果以订单状态为准。是否前往我的订单查看最新状态?',
confirmText: '查看订单',
cancelText: '稍后',
// #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) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/order-list/order-list' });
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(() => {
syncPackagePageState();
loadPackages();
loadWalletBalance();
});
</script>
@@ -430,141 +350,50 @@
justify-content: center;
padding: 120rpx 40rpx;
min-height: 400rpx;
.empty-icon {
font-size: 60rpx;
margin-bottom: 30rpx;
opacity: 0.6;
letter-spacing: 4rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: var(--text-tertiary);
text-align: center;
}
.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; }
}
.package-card {
margin-bottom: var(--space-md);
padding: 32rpx;
border: 1rpx solid rgba(0, 122, 255, 0.08);
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.06);
.package-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 28rpx;
.package-name {
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
flex: 1;
}
}
.package-main {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 20rpx;
padding: 26rpx;
margin-bottom: 22rpx;
background: linear-gradient(135deg, rgba(0, 122, 255, 0.08) 0%, rgba(0, 122, 255, 0.02) 100%);
border-radius: 24rpx;
.data-block {
flex: 1;
.data-label {
font-size: 24rpx;
color: var(--text-tertiary);
margin-bottom: 8rpx;
}
}
.package-data {
font-size: 52rpx;
font-weight: 800;
color: var(--primary);
line-height: 1.1;
letter-spacing: -1rpx;
}
.validity-box {
min-width: 132rpx;
margin-bottom: var(--space-sm);
.package-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
.header-right {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 18rpx 16rpx;
background: rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(0, 122, 255, 0.08);
border-radius: 20rpx;
.validity-value {
font-size: 34rpx;
font-weight: 800;
color: var(--text-primary);
line-height: 1;
}
.validity-label {
font-size: 22rpx;
color: var(--text-tertiary);
margin-top: 8rpx;
gap: 12rpx;
.validity-tag {
font-size: 24rpx;
color: var(--text-secondary);
padding: 4rpx 12rpx;
background: var(--gray-100);
border-radius: 8rpx;
}
}
}
.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);
line-height: 1.55;
margin-bottom: 24rpx;
margin-bottom: var(--space-md);
}
.package-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid var(--border-light);
.price-block {
display: flex;
align-items: baseline;
.package-price {
font-size: 40rpx;
font-weight: 700;
color: var(--warning);
white-space: nowrap;
.price-symbol {
font-size: 26rpx;
font-weight: 700;
margin-right: 4rpx;
}
.package-price {
font-size: 44rpx;
font-weight: 800;
letter-spacing: -1rpx;
}
}
.btn {
flex-shrink: 0;
min-width: 180rpx;
}
}
}
@@ -690,21 +519,6 @@
height: 64rpx;
}
.method-badge {
display: flex;
align-items: center;
justify-content: center;
border-radius: 18rpx;
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
.method-badge-alipay {
background: linear-gradient(135deg, #1677ff 0%, #45a5ff 100%);
box-shadow: 0 8rpx 20rpx rgba(22, 119, 255, 0.2);
}
.method-name {
font-size: 28rpx;
font-weight: 500;
@@ -756,10 +570,6 @@
font-weight: 500;
border: none;
&[disabled] {
opacity: 0.7;
}
&.btn-secondary {
background: var(--gray-200);
color: var(--text-primary);
@@ -772,4 +582,4 @@
}
}
}
</style>
</style>

View File

@@ -10,24 +10,20 @@
<view class="flex-row-sb mt-30">
<view class="flex-row-g20">
<view class="logo">
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
<image :src="getLogo(item.category).logo" mode="aspectFit"></image>
</view>
<view class="flex-col-g20">
<view class="flex-row-g20">
<view class="iccid">{{ item.iccid }}</view>
<view class="operator">
<up-tag type="success" size="mini">{{ getCarrier(item.carrier_type).name }}</up-tag>
<up-tag type="success" size="mini">{{ getLogo(item.category).name }}</up-tag>
</view>
</view>
<view class="flex-row-g20">
<view class="operator">
<up-tag :type="item.real_name_status === 1 ? 'primary' : 'success'" size="mini">{{ item.real_name_status === 1 ? '已实名' : '未实名' }}</up-tag>
</view>
<view v-if="item.network_status" class="operator">
<up-tag type="info" size="mini">{{ item.network_status }}</up-tag>
</view>
</view>
<view class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
</view>
</view>
</view>
@@ -38,20 +34,6 @@
<up-button class="btn-apple btn-success" v-else type="success" @tap="switchOperator(item)" :loading="switching">
切换此运营商
</up-button>
<up-button class="btn-apple btn-success" v-if="item.real_name_status !== 1" type="success" @tap="toReal(item)">
去实名
</up-button>
</view>
</view>
<view class="modal-overlay" v-if="showIccidModal" @tap="closeModal">
<view class="modal-content" @tap.stop>
<view class="modal-close" @tap="closeModal"></view>
<view class="modal-body">
<view class="modal-title">实名认证</view>
<view class="iccid-value">{{ currentModalIccid }}</view>
<view class="modal-button" @tap="doRealName">点击复制ICCID并跳转实名</view>
</view>
</view>
</view>
</view>
@@ -59,7 +41,7 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
import { assetApi, deviceApi, realnameApi } from '@/api/index.js';
import { assetApi, deviceApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
@@ -67,28 +49,16 @@
let mchList = reactive([]);
let loading = ref(false);
let switching = ref(false);
let showIccidModal = ref(false);
let currentModalIccid = ref('');
const carrierMap = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
};
let opratorList = reactive([
{ category: '124', logo: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500', name: '中国电信' },
{ category: '125', logo: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500', name: '中国联通' },
{ category: '126', logo: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182', name: '中国移动' }
]);
const carrierLogoMap = {
CMCC: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182',
CUCC: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
CTCC: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500',
CBN: 'https://img1.baidu.com/it/u=3160680953,3401650303&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
};
const getCarrier = (carrierType) => {
return {
name: carrierMap[carrierType] || '-',
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
};
const getLogo = (category) => {
const operator = opratorList.find(item => item.category === category);
return operator || opratorList[0];
};
const loadCards = async () => {
@@ -103,12 +73,11 @@
if (data.cards && data.cards.length > 0) {
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
iccid: card.iccid,
carrier_type: card.carrier_type,
carrier_name: card.carrier_name,
is_current: card.is_active,
real_name_status: card.real_name_status,
network_status: card.network_status,
slot_position: card.slot_position
slot_position: card.slot_position,
category: getCarrierCategory(card.carrier_name)
})));
}
} else {
@@ -116,12 +85,11 @@
if (assetData.identifier) {
mchList.splice(0, mchList.length, {
iccid: assetData.identifier,
carrier_type: assetData.carrier_type,
carrier_name: assetData.carrier_name,
is_current: true,
real_name_status: assetData.real_name_status,
network_status: assetData.network_status,
slot_position: 1
slot_position: 1,
category: getCarrierCategory(assetData.carrier_name)
});
}
}
@@ -131,6 +99,14 @@
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) => {
switching.value = true;
try {
@@ -143,38 +119,6 @@
switching.value = false;
};
const toReal = (card) => {
currentModalIccid.value = card.iccid;
showIccidModal.value = true;
};
const closeModal = () => {
showIccidModal.value = false;
};
const doRealName = async () => {
const iccid = currentModalIccid.value;
closeModal();
try {
const data = await realnameApi.getLink(userStore.state.identifier, iccid, { showError: false });
if (data.realname_url) {
uni.setClipboardData({
data: iccid,
success: () => {
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
}
});
setTimeout(() => {
window.location.href = data.realname_url;
}, 1500);
}
} catch (e) {
console.error('获取实名链接失败', e);
uni.showToast({ title: e?.msg || '获取实名链接失败', icon: 'none' });
}
};
onMounted(() => {
loadCards();
});
@@ -208,83 +152,12 @@
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);
}
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-content {
background: var(--bg-primary);
border-radius: var(--radius-large);
width: 620rpx;
max-width: 90%;
overflow: hidden;
position: relative;
}
.modal-close {
position: absolute;
top: 24rpx; left: 24rpx;
width: 48rpx; height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--gray-600);
cursor: pointer;
}
.modal-body {
padding: 60rpx 32rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 32rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.iccid-value {
font-size: 40rpx;
font-weight: bold;
color: var(--primary);
text-align: center;
word-break: break-all;
line-height: 1.4;
}
.modal-button {
padding: 24rpx 32rpx;
background: var(--success);
color: var(--text-inverse);
font-size: 28rpx;
font-weight: 500;
text-align: center;
border-radius: var(--radius-small);
cursor: pointer;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -296,4 +169,4 @@
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
}
</style>
</style>

795
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
allowBuilds:
'@parcel/watcher': set this to true or false
core-js: set this to true or false
esbuild: set this to true or false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -36,7 +36,7 @@ export const useUserStore = () => {
};
const setUserInfo = (userInfo) => {
Object.assign(state.userInfo, userInfo);
state.userInfo = userInfo;
uni.setStorageSync('userInfo', userInfo);
};

View File

@@ -1,3 +1,7 @@
export const BASE_URL = import.meta.env.VITE_BASE_URL;
export const BASE_URL = 'https://cmp-api.boss160.cn';
export const APP_TYPE = 'official_account';
export const APP_ID = 'wx404dc822ce15c5f0';
export const USE_WECHAT_AUTH = true;

View File

@@ -1,206 +0,0 @@
const ALIPAY_PENDING_REFRESH_KEY = 'pending_alipay_payment_refresh';
const ALIPAY_PAYMENT_DATA_KEY = 'pending_alipay_payment_data';
export const PAYMENT_REFRESH_TARGETS = {
PACKAGE_ORDER: 'package-order',
ORDER_LIST: 'order-list',
MY_WALLET: 'my-wallet'
};
export function isValidWechatPayConfig(payConfig) {
return !!payConfig &&
typeof payConfig.package === 'string' &&
payConfig.package.startsWith('prepay_id=');
}
export function getAlipayPaymentUrl(paymentLink) {
return getAlipayPaymentUrls(paymentLink).copyUrl;
}
export function getAlipayPaymentUrls(paymentLink) {
if (!paymentLink) {
return { copyUrl: '', qrUrl: '' };
}
if (typeof paymentLink === 'string') {
return { copyUrl: paymentLink, qrUrl: paymentLink };
}
return {
copyUrl: paymentLink.copy_link || paymentLink.qr_link || '',
qrUrl: paymentLink.qr_link || paymentLink.copy_link || ''
};
}
export function isValidAlipayPaymentLink(paymentLink) {
return !!getAlipayPaymentUrl(paymentLink);
}
export function markPendingPaymentRefresh(target) {
if (!target) return;
uni.setStorageSync(ALIPAY_PENDING_REFRESH_KEY, {
target,
timestamp: Date.now()
});
}
export function consumePendingPaymentRefresh(target) {
const pending = uni.getStorageSync(ALIPAY_PENDING_REFRESH_KEY);
if (!pending || pending.target !== target) {
return false;
}
uni.removeStorageSync(ALIPAY_PENDING_REFRESH_KEY);
return true;
}
export function getPendingAlipayPaymentData() {
return uni.getStorageSync(ALIPAY_PAYMENT_DATA_KEY) || null;
}
function encodePaymentUrl(value) {
try {
return btoa(unescape(encodeURIComponent(value)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
} catch (e) {
return '';
}
}
function getAlipayPaymentPageUrl(refreshTarget, payUrl, qrUrl) {
const params = [`t=${Date.now()}`];
if (refreshTarget) {
params.push(`from=${encodeURIComponent(refreshTarget)}`);
}
if (payUrl) {
const encodedPayUrl = encodePaymentUrl(payUrl);
if (encodedPayUrl) {
params.push(`pay64=${encodedPayUrl}`);
}
}
if (qrUrl) {
const encodedQrUrl = encodePaymentUrl(qrUrl);
if (encodedQrUrl) {
params.push(`qr64=${encodedQrUrl}`);
}
}
return `/pages/alipay-payment/alipay-payment?${params.join('&')}`;
}
function getAlipayPaymentBrowserUrl(pageUrl) {
if (typeof window === 'undefined' || !window.location) {
return pageUrl;
}
const { origin, pathname } = window.location;
const pagePath = pageUrl.replace(/^\//, '');
const basePath = pathname.includes('/pages/')
? pathname.slice(0, pathname.indexOf('/pages/'))
: pathname.replace(/\/[^/]*$/, '');
return `${origin}${basePath}/${pagePath}`;
}
function navigateToAlipayPaymentPage(pageUrl, resolve, reject) {
uni.navigateTo({
url: pageUrl,
success: () => resolve({ redirecting: true }),
fail: () => {
uni.redirectTo({
url: pageUrl,
success: () => resolve({ redirecting: true }),
fail: () => {
if (typeof window !== 'undefined' && window.location) {
window.location.href = getAlipayPaymentBrowserUrl(pageUrl);
resolve({ redirecting: true });
return;
}
reject(new Error('\u6253\u5f00\u652f\u4ed8\u5b9d\u652f\u4ed8\u9875\u5931\u8d25'));
}
});
}
});
}
export function openAlipayPayment(paymentLink, refreshTarget) {
return new Promise((resolve, reject) => {
const { copyUrl, qrUrl } = getAlipayPaymentUrls(paymentLink);
if (!copyUrl && !qrUrl) {
reject(new Error('\u652f\u4ed8\u94fe\u63a5\u65e0\u6548'));
return;
}
markPendingPaymentRefresh(refreshTarget);
uni.setStorageSync(ALIPAY_PAYMENT_DATA_KEY, {
copyUrl,
qrUrl,
refreshTarget,
timestamp: Date.now()
});
navigateToAlipayPaymentPage(getAlipayPaymentPageUrl(refreshTarget, copyUrl, qrUrl), resolve, reject);
});
}
export function wechatH5Pay(payConfig) {
return new Promise((resolve, reject) => {
if (!isValidWechatPayConfig(payConfig)) {
reject(new Error('\u652f\u4ed8\u53c2\u6570\u65e0\u6548'));
return;
}
if (typeof WeixinJSBridge === 'undefined') {
reject(new Error('\u8bf7\u5728\u5fae\u4fe1\u4e2d\u6253\u5f00'));
return;
}
WeixinJSBridge.invoke('getBrandWCPayRequest', {
appId: payConfig.app_id,
timeStamp: payConfig.timestamp,
nonceStr: payConfig.nonce_str,
package: payConfig.package,
signType: payConfig.sign_type,
paySign: payConfig.pay_sign
}, function(res) {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
resolve({
success: true,
message: '\u652f\u4ed8\u6210\u529f'
});
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
reject({
cancelled: true,
message: '\u5df2\u53d6\u6d88\u652f\u4ed8'
});
} else {
reject({
success: false,
message: '\u652f\u4ed8\u5931\u8d25',
error: res
});
}
});
});
}
export function showPaymentToast(success, message) {
uni.showToast({
title: message || (success ? '\u652f\u4ed8\u6210\u529f' : '\u652f\u4ed8\u5931\u8d25'),
icon: success ? 'success' : 'none',
duration: 2000
});
}
export function handlePaymentError(error) {
if (error.cancelled) {
showPaymentToast(false, '\u5df2\u53d6\u6d88\u652f\u4ed8');
return;
}
showPaymentToast(false, error.message || '\u652f\u4ed8\u5931\u8d25');
}

View File

@@ -1,13 +1,5 @@
import { BASE_URL } from './env.js';
const showErrorToast = (message) => {
uni.showToast({
title: message || '请求失败,请稍后重试',
icon: 'none',
duration: 2000
});
};
const request = (options) => {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') || '';
@@ -22,36 +14,44 @@ const request = (options) => {
...options.header
},
success: (res) => {
const responseData = res.data || {};
const { code, msg, data } = responseData;
if (res.statusCode === 401) {
uni.removeStorageSync('token');
uni.removeStorageSync('identifier');
showErrorToast(msg || '登录已过期,请重新登录');
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
duration: 2000
});
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/login' });
}, 2000);
reject(responseData);
reject(res.data);
return;
}
if (code === 0) {
const { code, msg, data } = res.data;
if (code === 0 || code === 200) {
resolve(data);
return;
} else {
uni.showToast({
title: msg || '请求失败',
icon: 'none',
duration: 2000
});
reject(res.data);
}
if (options.showError !== false) {
showErrorToast(msg);
}
reject(responseData);
},
fail: (err) => {
showErrorToast('网络请求失败');
uni.showToast({
title: '网络请求失败',
icon: 'none',
duration: 2000
});
reject(err);
}
});
});
};
export default request;
export default request;

View File

@@ -1,504 +0,0 @@
import { regionData } from 'element-china-area-data';
const GENERIC_CITY_LABELS = new Set([
'市辖区',
'县',
'自治区直辖县级行政区划',
'省直辖县级行政区划'
]);
const NAME_LABELS = ['收货人', '收件人', '联系人', '姓名'];
const PHONE_LABELS = ['手机号码', '手机号', '联系电话', '联系方式', '电话', '手机'];
const REGION_LABELS = ['所在地区', '地区'];
const DETAIL_LABELS = ['详细地址', '收货地址', '地址'];
const ALL_FIELD_LABELS = [
...new Set([...NAME_LABELS, ...PHONE_LABELS, ...REGION_LABELS, ...DETAIL_LABELS])
];
const PHONE_REG = /(?<!\d)(?:\+?86[-\s]?)?(1[3-9](?:[\s-]?\d){9})(?!\d)/;
const ADDRESS_SIGNAL_REG = /(省|市|区|县|旗|乡|镇|街道|大道|路|街|巷|弄|村|社区|小区|号|栋|单元|室|楼)/;
const FIELD_LABEL_PATTERN = createLabelPattern(ALL_FIELD_LABELS);
const STRIP_FIELD_LABEL_REG = new RegExp(
`(^|[\\s,;\\n])(?:${FIELD_LABEL_PATTERN})\\s*[:]`,
'g'
);
const provinceOptions = regionData.map(({ label, value }) => ({ label, value }));
const districtRecords = regionData.flatMap((province) =>
(province.children || []).flatMap((city) =>
(city.children || []).map((district) => {
const provinceAliases = createProvinceAliases(province.label);
const cityAliases = createCityAliases(getDisplayCityLabel(city.label));
const districtAliases = createDistrictAliases(district.label);
return {
province,
city,
district,
provinceAliases,
cityAliases,
districtAliases,
provinceSearchAliases: provinceAliases.map(normalizeForSearch).filter(Boolean),
citySearchAliases: cityAliases.map(normalizeForSearch).filter(Boolean),
districtSearchAliases: districtAliases.map(normalizeForSearch).filter(Boolean),
fullSearchLabels: buildFullSearchLabels(provinceAliases, cityAliases, districtAliases)
};
})
)
);
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function createLabelPattern(labels = []) {
return labels
.slice()
.sort((left, right) => right.length - left.length)
.map((label) => escapeRegExp(label))
.join('|');
}
function normalizeText(value = '') {
return String(value)
.replace(/\r/g, '\n')
.replace(/\u00a0/g, ' ')
.replace(/\t/g, ' ')
.trim();
}
function normalizeForSearch(value = '') {
return normalizeText(value).replace(/[\s,.。;::、\-_/]/g, '');
}
function stripProvinceSuffix(label = '') {
return normalizeText(label).replace(
/(特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|省|市)$/u,
''
);
}
function stripCitySuffix(label = '') {
return normalizeText(label).replace(/(自治州|地区|盟|市)$/u, '');
}
function stripDistrictSuffix(label = '') {
return normalizeText(label).replace(/(自治县|自治旗|区|县|旗|市)$/u, '');
}
function createAliases(label = '', stripFn = null) {
const rawLabel = normalizeText(label);
const aliases = new Set([rawLabel]);
const simplifiedLabel = stripFn ? normalizeText(stripFn(rawLabel)) : '';
if (simplifiedLabel && simplifiedLabel !== rawLabel) {
aliases.add(simplifiedLabel);
}
return [...aliases].filter(Boolean);
}
function createProvinceAliases(label = '') {
return createAliases(label, stripProvinceSuffix);
}
function createCityAliases(label = '') {
if (!label) return [''];
return createAliases(label, stripCitySuffix);
}
function createDistrictAliases(label = '') {
return createAliases(label, stripDistrictSuffix);
}
function buildFullSearchLabels(provinceAliases = [], cityAliases = [], districtAliases = []) {
const fullLabels = new Set();
const normalizedCityAliases = cityAliases.length ? cityAliases : [''];
provinceAliases.forEach((provinceAlias) => {
normalizedCityAliases.forEach((cityAlias) => {
districtAliases.forEach((districtAlias) => {
const combinedLabel = normalizeForSearch(`${provinceAlias}${cityAlias}${districtAlias}`);
if (combinedLabel) {
fullLabels.add(combinedLabel);
}
});
});
});
return [...fullLabels];
}
function getDisplayCityLabel(label = '') {
return GENERIC_CITY_LABELS.has(label) ? '' : label;
}
function getProvinceByCode(provinceCode) {
return regionData.find((item) => item.value === provinceCode) || null;
}
function getCityByCode(provinceCode, cityCode) {
const province = getProvinceByCode(provinceCode);
return province?.children?.find((item) => item.value === cityCode) || null;
}
function getDistrictByCode(provinceCode, cityCode, districtCode) {
const city = getCityByCode(provinceCode, cityCode);
return city?.children?.find((item) => item.value === districtCode) || null;
}
function getCityOptions(provinceCode) {
const province = getProvinceByCode(provinceCode) || regionData[0];
return (province?.children || []).map(({ label, value }) => ({ label, value }));
}
function getDistrictOptions(provinceCode, cityCode) {
const province = getProvinceByCode(provinceCode) || regionData[0];
const city =
province?.children?.find((item) => item.value === cityCode) ||
province?.children?.[0] ||
null;
return (city?.children || []).map(({ label, value }) => ({ label, value }));
}
function getLabelParts(regionCodes = []) {
const [provinceCode, cityCode, districtCode] = regionCodes;
const province = getProvinceByCode(provinceCode);
const city = getCityByCode(provinceCode, cityCode);
const district = getDistrictByCode(provinceCode, cityCode, districtCode);
if (!province || !city || !district) {
return [];
}
const displayCityLabel = getDisplayCityLabel(city.label);
return [province.label, displayCityLabel, district.label].filter(Boolean);
}
function stripFieldLabels(value = '') {
return normalizeText(value).replace(STRIP_FIELD_LABEL_REG, '$1');
}
function cleanupAddressText(value = '') {
return stripFieldLabels(value)
.replace(/[:]/g, ' ')
.replace(/[,;]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function cleanupName(value = '') {
return normalizeText(value)
.replace(/^[\s,;:]+/, '')
.replace(/[\s,;:]+$/, '')
.trim();
}
function findBestAliasInText(text = '', aliases = []) {
return aliases
.slice()
.sort((left, right) => right.length - left.length)
.find((alias) => alias && text.includes(alias)) || '';
}
function extractPhone(rawText = '') {
const text = normalizeText(rawText);
const match = text.match(PHONE_REG);
if (!match) {
return {
phone: '',
textWithoutPhone: text
};
}
const digits = match[1].replace(/\D/g, '');
const phone = digits.length > 11 ? digits.slice(-11) : digits;
return {
phone,
textWithoutPhone: text.replace(match[0], ' ').replace(/\s+/g, ' ').trim()
};
}
function pickBestMatch(records, matcher) {
return records
.filter(matcher)
.sort((left, right) => {
return (
right.fullSearchLabels[0]?.length - left.fullSearchLabels[0]?.length ||
right.district.label.length - left.district.label.length
);
})[0] || null;
}
function matchAnyAlias(searchText = '', aliases = []) {
return aliases.some((alias) => alias && searchText.includes(alias));
}
function findRegionMatch(rawText = '') {
const searchText = normalizeForSearch(rawText);
if (!searchText) {
return null;
}
let match = pickBestMatch(districtRecords, (record) => {
return record.fullSearchLabels.some((label) => searchText.includes(label));
});
if (!match) {
match = pickBestMatch(districtRecords, (record) => {
return (
matchAnyAlias(searchText, record.provinceSearchAliases) &&
matchAnyAlias(searchText, record.districtSearchAliases) &&
(!record.citySearchAliases.length || matchAnyAlias(searchText, record.citySearchAliases))
);
});
}
if (!match) {
match = pickBestMatch(districtRecords, (record) => {
return (
matchAnyAlias(searchText, record.citySearchAliases) &&
matchAnyAlias(searchText, record.districtSearchAliases)
);
});
}
return match;
}
function getRegionStartIndex(text, match) {
const candidates = [
findBestAliasInText(text, match.provinceAliases),
findBestAliasInText(text, match.cityAliases),
findBestAliasInText(text, match.districtAliases)
]
.filter(Boolean)
.map((label) => text.indexOf(label))
.filter((index) => index >= 0);
return candidates.length ? Math.min(...candidates) : -1;
}
function stripMatchedRegion(addressText, match) {
let detailAddress = normalizeText(addressText);
const matchedProvince = findBestAliasInText(detailAddress, match.provinceAliases);
const matchedCity = findBestAliasInText(detailAddress, match.cityAliases);
const matchedDistrict = findBestAliasInText(detailAddress, match.districtAliases);
[matchedProvince, matchedCity, matchedDistrict]
.filter(Boolean)
.forEach((label) => {
detailAddress = detailAddress.replace(label, '');
});
return cleanupAddressText(detailAddress);
}
function extractNameFromText(rawText = '', textWithoutPhone = '', regionStartIndex = -1) {
const sourceText =
regionStartIndex > 0 ? textWithoutPhone.slice(0, regionStartIndex) : textWithoutPhone || rawText;
const directNameMatch = normalizeText(rawText).match(
/(?:收货人|收件人|联系人|姓名)\s*[:]?\s*([A-Za-z\u4e00-\u9fa5·]{2,20})/u
);
if (directNameMatch?.[1]) {
return directNameMatch[1].trim();
}
const cleaned = cleanupAddressText(sourceText)
.replace(PHONE_REG, ' ')
.replace(/\d+/g, ' ')
.trim();
const tokens = cleaned.split(/\s+/).filter(Boolean);
const nameToken = tokens.find((token) => {
return !ADDRESS_SIGNAL_REG.test(token) && /^[A-Za-z\u4e00-\u9fa5·]{2,20}$/u.test(token);
});
return nameToken || '';
}
function extractAddressSource(textWithoutPhone = '', name = '', match = null) {
let addressSource = normalizeText(textWithoutPhone);
if (name) {
addressSource = addressSource.replace(name, ' ').replace(/\s+/g, ' ').trim();
}
if (match) {
const regionStartIndex = getRegionStartIndex(addressSource, match);
if (regionStartIndex >= 0) {
return addressSource.slice(regionStartIndex).trim();
}
}
return cleanupAddressText(addressSource);
}
function formatRegionCodes(match) {
return [match.province.value, match.city.value, match.district.value];
}
function combineDetailParts(parts = []) {
return parts
.map((item) => cleanupAddressText(item))
.filter(Boolean)
.join('');
}
function extractFieldValue(text = '', labels = []) {
if (!text || !labels.length) {
return '';
}
const labelPattern = createLabelPattern(labels);
const fieldReg = new RegExp(
`(?:${labelPattern})\\s*[:]\\s*([\\s\\S]*?)(?=(?:[\\n,]\\s*)?(?:${FIELD_LABEL_PATTERN})\\s*[:]|$)`,
'u'
);
const match = normalizeText(text).match(fieldReg);
if (!match?.[1]) {
return '';
}
return normalizeText(match[1]).replace(/^[,\s]+|[,\s]+$/g, '');
}
function extractLabeledFields(text = '') {
return {
name: extractFieldValue(text, NAME_LABELS),
phone: extractFieldValue(text, PHONE_LABELS),
region: extractFieldValue(text, REGION_LABELS),
detail: extractFieldValue(text, DETAIL_LABELS)
};
}
function parseFieldBasedShippingText(text = '') {
const fields = extractLabeledFields(text);
const hasAnyField = Object.values(fields).some(Boolean);
if (!hasAnyField) {
return null;
}
const recipientName = cleanupName(fields.name);
const recipientPhone = extractPhone(fields.phone || text).phone;
const regionSource = normalizeText(fields.region);
const detailSource = normalizeText(fields.detail);
let regionMatch = findRegionMatch(regionSource || `${detailSource}${text}`);
if (!regionMatch) {
regionMatch = findRegionMatch(text);
}
const detailParts = [];
if (regionSource) {
const regionRemainder = regionMatch ? stripMatchedRegion(regionSource, regionMatch) : regionSource;
if (regionRemainder) {
detailParts.push(regionRemainder);
}
}
if (detailSource) {
detailParts.push(detailSource);
}
return {
recipient_name: recipientName,
recipient_phone: recipientPhone,
regionCodes: regionMatch ? formatRegionCodes(regionMatch) : [],
detailAddress: combineDetailParts(detailParts)
};
}
export function buildRegionColumns(regionCodes = []) {
const provinceCode =
getProvinceByCode(regionCodes[0])?.value || provinceOptions[0]?.value || '';
const cityOptions = getCityOptions(provinceCode);
const cityCode =
cityOptions.find((item) => item.value === regionCodes[1])?.value ||
cityOptions[0]?.value ||
'';
const districtOptions = getDistrictOptions(provinceCode, cityCode);
const districtCode =
districtOptions.find((item) => item.value === regionCodes[2])?.value ||
districtOptions[0]?.value ||
'';
return {
columns: [provinceOptions, cityOptions, districtOptions],
defaultIndex: [
provinceOptions.findIndex((item) => item.value === provinceCode),
cityOptions.findIndex((item) => item.value === cityCode),
districtOptions.findIndex((item) => item.value === districtCode)
]
};
}
export function getRegionDisplayText(regionCodes = []) {
return getLabelParts(regionCodes).join(' / ');
}
export function composeRecipientAddress(regionCodes = [], detailAddress = '') {
const regionText = getLabelParts(regionCodes).join('');
return `${regionText}${normalizeText(detailAddress)}`.trim();
}
export function parseSavedRecipientAddress(rawAddress = '') {
const addressText = normalizeText(rawAddress);
if (!addressText) {
return {
regionCodes: [],
detailAddress: ''
};
}
const match = findRegionMatch(addressText);
if (!match) {
return {
regionCodes: [],
detailAddress: addressText
};
}
const detailAddress = stripMatchedRegion(addressText, match);
return {
regionCodes: formatRegionCodes(match),
detailAddress: detailAddress || addressText
};
}
export function parseShippingText(rawText = '') {
const text = normalizeText(rawText);
if (!text) {
return {
recipient_name: '',
recipient_phone: '',
regionCodes: [],
detailAddress: ''
};
}
const fieldBasedResult = parseFieldBasedShippingText(text);
if (fieldBasedResult) {
return fieldBasedResult;
}
const { phone, textWithoutPhone } = extractPhone(text);
const regionMatch = findRegionMatch(textWithoutPhone);
const regionStartIndex = regionMatch ? getRegionStartIndex(textWithoutPhone, regionMatch) : -1;
const recipientName = extractNameFromText(text, textWithoutPhone, regionStartIndex);
const addressSource = extractAddressSource(textWithoutPhone, recipientName, regionMatch);
const detailAddress = regionMatch
? stripMatchedRegion(addressSource, regionMatch)
: cleanupAddressText(addressSource);
return {
recipient_name: recipientName,
recipient_phone: phone,
regionCodes: regionMatch ? formatRegionCodes(regionMatch) : [],
detailAddress
};
}

View File

@@ -1,110 +0,0 @@
// utils/wxsdk.js —— 封装微信扫一扫 不用旧的了
import { wechatApi } from '@/api/index.js'
const getWx = () => window.jWeixin || window.wx
const entryUrl = window.__WX_ENTRY_URL__ || window.location.href.split('#')[0]
let configPromise = null
let configUrl = ''
let configReady = false
const isIOS = () => /iphone|ipad|ipod/i.test(navigator.userAgent)
const getConfigUrl = () => {
// iOS 微信 WebView 的 JS-SDK 签名 URL 需要使用 SPA 首次进入的地址。
if (isIOS()) {
return entryUrl
}
return window.location.href.split('#')[0]
}
/**
* 初始化微信 JS-SDK
*/
export async function initWxConfig() {
const wx = getWx()
if (!wx) {
throw new Error('微信 SDK 未加载')
}
const url = getConfigUrl()
console.log(url);
if (configReady && configUrl === url) {
return
}
if (configPromise && configUrl === url) {
return configPromise
}
configUrl = url
configReady = false
configPromise = wechatApi.getJssdkConfig(url).then((data) => {
const { app_id, timestamp, nonce_str, signature } = data
return new Promise((resolve, reject) => {
wx.config({
debug: false,
appId: app_id,
timestamp: timestamp,
nonceStr: nonce_str,
signature: signature,
jsApiList: ['scanQRCode']
})
wx.ready(() => {
console.log('微信 SDK 就绪')
configReady = true
resolve()
})
wx.error((err) => {
console.error('wx.config 失败', err)
configReady = false
configPromise = null
reject(err)
})
})
}).catch((e) => {
console.error('获取微信签名失败', e)
configPromise = null
configReady = false
throw e
})
return configPromise
}
/**
* 调起微信扫一扫
* @returns {Promise<string>} 扫码结果字符串
*/
export async function wxScan() {
await initWxConfig()
const wx = getWx()
if (!wx) {
throw new Error('微信 SDK 未加载')
}
return new Promise((resolve, reject) => {
wx.scanQRCode({
needResult: 1,
scanType: ['qrCode', 'barCode'],
success(res) {
console.log('扫码成功:', res)
resolve(res.resultStr)
},
fail(err) {
console.error('扫码失败:', err)
reject(err)
}
})
})
}
/**
* 检查是否在微信环境中
*/
export function isInWechat() {
return /micromessenger/i.test(navigator.userAgent)
}

View File

@@ -1,45 +1,6 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
const mpVerifyFiles = [
'MP_verify_0UoX8yClVgREjgPj.txt',
'MP_verify_4E1dVbOcZ9KzTzoc.txt',
'MP_verify_yU2Z8mp831jh6QX7.txt'
]
function copyMpVerifyFiles() {
return {
name: 'copy-mp-verify-files',
closeBundle() {
const outputDir = resolve(process.cwd(), 'dist/build/h5')
if (!existsSync(outputDir)) {
return
}
mkdirSync(outputDir, { recursive: true })
mpVerifyFiles.forEach((file) => {
const source = resolve(process.cwd(), file)
if (existsSync(source)) {
copyFileSync(source, resolve(outputDir, file))
}
})
}
}
}
export default defineConfig({
plugins: [uni(), copyMpVerifyFiles()],
root: process.cwd(),
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ['legacy-js-api', 'import']
}
}
}
plugins: [uni()],
})