Compare commits
4 Commits
remove-aig
...
987e3cfefc
| Author | SHA1 | Date | |
|---|---|---|---|
| 987e3cfefc | |||
|
|
64c73ad6a6 | ||
| f0843e0f12 | |||
|
|
14defcc5b7 |
@@ -1,176 +0,0 @@
|
||||
# TopNavBar 顶部导航栏组件
|
||||
|
||||
一个功能完整的顶部导航栏组件,支持固定定位、自定义样式和插槽内容。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 支持固定在页面顶部(可配置)
|
||||
- ✅ 自动适配状态栏高度
|
||||
- ✅ 支持自定义标题和颜色
|
||||
- ✅ 支持插槽自定义内容
|
||||
- ✅ 内置返回按钮功能
|
||||
- ✅ 响应式设计
|
||||
- ✅ 深色模式支持
|
||||
- ✅ 安全区域适配
|
||||
|
||||
## 基础用法
|
||||
|
||||
### 简单使用
|
||||
```vue
|
||||
<template>
|
||||
<TopNavBar title="页面标题" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavBar from '@/components/TopNavBar/index.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
### 固定在顶部
|
||||
```vue
|
||||
<template>
|
||||
<TopNavBar title="页面标题" :fixed="true" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 自定义样式
|
||||
```vue
|
||||
<template>
|
||||
<TopNavBar
|
||||
title="页面标题"
|
||||
backgroundColor="#007AFF"
|
||||
titleColor="#ffffff"
|
||||
backIconColor="#ffffff"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 标题对齐方式
|
||||
```vue
|
||||
<template>
|
||||
<!-- 标题居中显示(默认) -->
|
||||
<TopNavBar title="居中标题" titleAlign="center" />
|
||||
|
||||
<!-- 标题左对齐显示 -->
|
||||
<TopNavBar title="左对齐标题" titleAlign="left" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 使用插槽
|
||||
```vue
|
||||
<template>
|
||||
<TopNavBar>
|
||||
<template #title>
|
||||
<view class="custom-title">
|
||||
<text>自定义标题内容</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<uni-icons type="more" size="20" color="#333" />
|
||||
</template>
|
||||
</TopNavBar>
|
||||
</template>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| title | String | '' | 导航栏标题 |
|
||||
| fixed | Boolean | false | 是否固定在页面顶部 |
|
||||
| showBack | Boolean | true | 是否显示返回按钮 |
|
||||
| backgroundColor | String | '#ffffff' | 背景颜色 |
|
||||
| titleColor | String | '#333333' | 标题文字颜色 |
|
||||
| backIconColor | String | '#333333' | 返回按钮图标颜色 |
|
||||
| hideStatusBar | Boolean | false | 是否隐藏状态栏占位 |
|
||||
| zIndex | Number | 999 | 层级索引 |
|
||||
| titleAlign | String | 'center' | 标题对齐方式,可选值:'center'、'left' |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件名 | 说明 | 参数 |
|
||||
|--------|------|------|
|
||||
| back | 点击返回按钮时触发 | - |
|
||||
|
||||
### Slots
|
||||
|
||||
| 插槽名 | 说明 |
|
||||
|--------|------|
|
||||
| title | 自定义标题内容 |
|
||||
| right | 自定义右侧内容 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 订单列表页面
|
||||
```vue
|
||||
<template>
|
||||
<view>
|
||||
<TopNavBar>
|
||||
<template #title>
|
||||
<Tabs
|
||||
:tabs="tabList"
|
||||
:defaultActive="currentTabIndex"
|
||||
@change="handleTabChange"
|
||||
/>
|
||||
</template>
|
||||
</TopNavBar>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<view class="page-content">
|
||||
<!-- 内容区域 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 商品详情页面
|
||||
```vue
|
||||
<template>
|
||||
<view>
|
||||
<TopNavBar
|
||||
title="商品详情"
|
||||
:fixed="true"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #right>
|
||||
<uni-icons type="share" size="20" color="#333" />
|
||||
</template>
|
||||
</TopNavBar>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<view class="page-content">
|
||||
<!-- 内容区域 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const handleBack = () => {
|
||||
// 自定义返回逻辑
|
||||
console.log('自定义返回处理')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **固定定位使用**:当设置 `fixed="true"` 时,组件会固定在页面顶部,此时需要为页面内容添加适当的顶部间距。
|
||||
|
||||
2. **状态栏适配**:组件会自动获取系统状态栏高度并进行适配,无需手动处理。
|
||||
|
||||
3. **返回按钮**:默认点击返回按钮会执行 `uni.navigateBack()`,如果需要自定义返回逻辑,请监听 `@back` 事件。
|
||||
|
||||
4. **样式覆盖**:如需自定义样式,建议通过 props 传入颜色值,或在父组件中使用深度选择器覆盖样式。
|
||||
|
||||
5. **插槽使用**:title 插槽会完全替换默认的标题显示,right 插槽用于添加右侧操作按钮。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- 初始版本发布
|
||||
- 支持基础导航栏功能
|
||||
- 支持固定定位配置
|
||||
- 支持自定义样式和插槽
|
||||
@@ -1,151 +0,0 @@
|
||||
<template>
|
||||
<view class="demo-container">
|
||||
<!-- 示例1: 基础用法 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">基础用法</view>
|
||||
<TopNavBar title="基础导航栏" />
|
||||
</view>
|
||||
|
||||
<!-- 示例2: 固定在顶部 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">固定在顶部</view>
|
||||
<TopNavBar title="固定导航栏" :fixed="true" />
|
||||
</view>
|
||||
|
||||
<!-- 示例3: 自定义颜色 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">自定义颜色</view>
|
||||
<TopNavBar
|
||||
title="蓝色导航栏"
|
||||
backgroundColor="#007AFF"
|
||||
titleColor="#ffffff"
|
||||
backIconColor="#ffffff"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 示例4: 隐藏返回按钮 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">隐藏返回按钮</view>
|
||||
<TopNavBar title="无返回按钮" :showBack="false" />
|
||||
</view>
|
||||
|
||||
<!-- 示例5: 标题左对齐 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">标题左对齐</view>
|
||||
<TopNavBar title="左对齐标题" titleAlign="left" />
|
||||
</view>
|
||||
|
||||
<!-- 示例6: 标题居中对齐(默认) -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">标题居中对齐(默认)</view>
|
||||
<TopNavBar title="居中对齐标题" titleAlign="center" />
|
||||
</view>
|
||||
|
||||
<!-- 示例7: 使用插槽 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">使用插槽</view>
|
||||
<TopNavBar>
|
||||
<template #title>
|
||||
<view class="custom-title">
|
||||
<uni-icons type="star" size="16" color="#FFD700" />
|
||||
<text class="title-text">自定义标题</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<view class="right-actions">
|
||||
<uni-icons type="search" size="20" color="#333" style="margin-right: 10px;" />
|
||||
<uni-icons type="more" size="20" color="#333" />
|
||||
</view>
|
||||
</template>
|
||||
</TopNavBar>
|
||||
</view>
|
||||
|
||||
<!-- 示例8: 渐变背景 -->
|
||||
<view class="demo-section">
|
||||
<view class="demo-title">渐变背景</view>
|
||||
<TopNavBar
|
||||
title="渐变导航栏"
|
||||
backgroundColor="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
titleColor="#ffffff"
|
||||
backIconColor="#ffffff"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view class="content-area">
|
||||
<view class="content-item" v-for="i in 20" :key="i">
|
||||
<text>内容项 {{ i }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavBar from './index.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.demo-container {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
margin-bottom: 30px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
padding: 15px 20px;
|
||||
background-color: #f8f9fa;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.custom-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.title-text {
|
||||
margin-left: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content-item {
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -31,7 +31,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
@@ -85,14 +85,11 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customBack: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 定义emits
|
||||
const emit = defineEmits(["back"]);
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
// 系统信息
|
||||
const statusBarHeight = ref(0);
|
||||
@@ -148,9 +145,23 @@ const navContentStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const hasBackListener = computed(() => {
|
||||
const vnodeProps = instance?.vnode?.props || {};
|
||||
return Boolean(vnodeProps.onBack);
|
||||
});
|
||||
|
||||
// 处理返回事件
|
||||
const handleBack = () => {
|
||||
if (props.customBack) {
|
||||
const pages = getCurrentPages();
|
||||
|
||||
if (pages.length <= 1) {
|
||||
uni.reLaunch({
|
||||
url: "/pages/index/index",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasBackListener.value) {
|
||||
emit("back");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
# TopNavBar 组件使用指南
|
||||
|
||||
## 组件概述
|
||||
|
||||
TopNavBar 是一个功能完整的顶部导航栏组件,专为 uni-app 项目设计。该组件支持固定定位、自定义样式、插槽内容等功能,可以满足大部分页面的导航需求。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 可配置固定定位
|
||||
- **默认行为**: 组件默认不固定,跟随页面滚动
|
||||
- **固定模式**: 设置 `fixed="true"` 可将导航栏固定在页面顶部
|
||||
- **自动适配**: 固定模式下自动处理状态栏高度和安全区域
|
||||
|
||||
### 2. 智能状态栏适配
|
||||
- 自动获取系统状态栏高度
|
||||
- 支持不同平台的导航栏高度适配(iOS: 44px, Android: 48px)
|
||||
- 可选择隐藏状态栏占位区域
|
||||
|
||||
### 3. 灵活的自定义选项
|
||||
- 支持自定义背景色、标题色、图标色
|
||||
- 可控制返回按钮显示/隐藏
|
||||
- 支持自定义 z-index 层级
|
||||
- 支持标题对齐方式配置(居中/左对齐)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基础使用
|
||||
|
||||
```vue
|
||||
<!-- 最简单的使用方式 -->
|
||||
<TopNavBar title="页面标题" />
|
||||
```
|
||||
|
||||
### 固定在顶部
|
||||
|
||||
```vue
|
||||
<!-- 固定导航栏,适合长页面滚动 -->
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<TopNavBar title="商品详情" :fixed="true" />
|
||||
<view class="page-content">
|
||||
<!-- 页面内容 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.page-content {
|
||||
/* 为固定导航栏预留空间 */
|
||||
padding-top: calc(var(--status-bar-height, 44px) + 44px);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 自定义样式
|
||||
|
||||
```vue
|
||||
<!-- 深色主题导航栏 -->
|
||||
<TopNavBar
|
||||
title="深色导航栏"
|
||||
backgroundColor="#1a1a1a"
|
||||
titleColor="#ffffff"
|
||||
backIconColor="#ffffff"
|
||||
/>
|
||||
|
||||
<!-- 品牌色导航栏 -->
|
||||
<TopNavBar
|
||||
title="品牌导航栏"
|
||||
backgroundColor="#007AFF"
|
||||
titleColor="#ffffff"
|
||||
backIconColor="#ffffff"
|
||||
/>
|
||||
|
||||
<!-- 左对齐标题 -->
|
||||
<TopNavBar
|
||||
title="左对齐标题"
|
||||
titleAlign="left"
|
||||
/>
|
||||
|
||||
<!-- 居中标题(默认) -->
|
||||
<TopNavBar
|
||||
title="居中标题"
|
||||
titleAlign="center"
|
||||
/>
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 使用插槽自定义内容
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TopNavBar>
|
||||
<!-- 自定义标题区域 -->
|
||||
<template #title>
|
||||
<view class="custom-title">
|
||||
<image src="/static/logo.png" class="logo" />
|
||||
<text class="brand-name">品牌名称</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 自定义右侧操作 -->
|
||||
<template #right>
|
||||
<view class="nav-actions">
|
||||
<uni-icons type="search" size="20" @click="handleSearch" />
|
||||
<uni-icons type="more" size="20" @click="showMore" />
|
||||
</view>
|
||||
</template>
|
||||
</TopNavBar>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.custom-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 监听返回事件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TopNavBar
|
||||
title="自定义返回"
|
||||
@back="handleCustomBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const handleCustomBack = () => {
|
||||
// 自定义返回逻辑
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要离开当前页面吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 实际应用场景
|
||||
|
||||
### 1. 商品详情页
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="goods-detail">
|
||||
<TopNavBar
|
||||
title="商品详情"
|
||||
:fixed="true"
|
||||
backgroundColor="rgba(255, 255, 255, 0.95)"
|
||||
>
|
||||
<template #right>
|
||||
<uni-icons type="share" size="20" @click="shareGoods" />
|
||||
</template>
|
||||
</TopNavBar>
|
||||
|
||||
<view class="goods-content">
|
||||
<!-- 商品内容 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. 订单列表页
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="order-list">
|
||||
<TopNavBar>
|
||||
<template #title>
|
||||
<Tabs
|
||||
:tabs="orderTabs"
|
||||
:active="activeTab"
|
||||
@change="switchTab"
|
||||
/>
|
||||
</template>
|
||||
</TopNavBar>
|
||||
|
||||
<view class="order-content">
|
||||
<!-- 订单列表 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. 聊天页面
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="chat-page">
|
||||
<TopNavBar
|
||||
title="客服小沐"
|
||||
:fixed="true"
|
||||
backgroundColor="#f8f9fa"
|
||||
>
|
||||
<template #right>
|
||||
<uni-icons type="phone" size="20" @click="makeCall" />
|
||||
</template>
|
||||
</TopNavBar>
|
||||
|
||||
<view class="chat-content">
|
||||
<!-- 聊天内容 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 固定导航栏的页面布局
|
||||
|
||||
```scss
|
||||
// 推荐的页面结构
|
||||
.page-container {
|
||||
.page-content {
|
||||
// 方法1: 使用 padding-top
|
||||
padding-top: calc(var(--status-bar-height, 44px) + 44px);
|
||||
|
||||
// 方法2: 使用 margin-top
|
||||
// margin-top: calc(var(--status-bar-height, 44px) + 44px);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 响应式设计
|
||||
|
||||
```scss
|
||||
// 适配不同屏幕尺寸
|
||||
@media screen and (max-width: 375px) {
|
||||
.page-content {
|
||||
padding-top: calc(var(--status-bar-height, 44px) + 40px);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 主题适配
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 根据系统主题动态调整颜色
|
||||
const navBarStyle = computed(() => {
|
||||
const isDark = uni.getSystemInfoSync().theme === 'dark'
|
||||
return {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#ffffff',
|
||||
titleColor: isDark ? '#ffffff' : '#333333',
|
||||
backIconColor: isDark ? '#ffffff' : '#333333'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TopNavBar
|
||||
title="自适应主题"
|
||||
:backgroundColor="navBarStyle.backgroundColor"
|
||||
:titleColor="navBarStyle.titleColor"
|
||||
:backIconColor="navBarStyle.backIconColor"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **固定定位的性能考虑**: 固定导航栏会创建新的层叠上下文,在复杂页面中可能影响性能
|
||||
|
||||
2. **状态栏适配**: 在不同设备上状态栏高度可能不同,组件会自动处理,但建议在测试时验证各种设备
|
||||
|
||||
3. **插槽内容**: 使用插槽时注意内容的响应式设计,确保在不同屏幕尺寸下都能正常显示
|
||||
|
||||
4. **z-index 管理**: 如果页面中有其他固定定位元素,注意调整 z-index 避免层级冲突
|
||||
|
||||
5. **返回按钮**: 默认返回行为是 `uni.navigateBack()`,如需自定义请监听 `@back` 事件
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 固定导航栏下的内容被遮挡了?**
|
||||
A: 需要为页面内容添加顶部间距,参考上面的最佳实践。
|
||||
|
||||
**Q: 在某些设备上状态栏高度不正确?**
|
||||
A: 组件会自动获取状态栏高度,如果仍有问题,可以手动设置 `hideStatusBar="true"` 并自行处理。
|
||||
|
||||
**Q: 自定义颜色不生效?**
|
||||
A: 确保传入的颜色值格式正确,支持 hex、rgb、rgba 等标准 CSS 颜色格式。
|
||||
|
||||
**Q: 插槽内容显示异常?**
|
||||
A: 检查插槽内容的样式,确保没有影响导航栏布局的 CSS 属性。
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view :class="['custom-card', { 'is-selected': selected }]" @tap="emit('select')">
|
||||
<text class="custom-title">自选金额</text>
|
||||
|
||||
<label class="custom-input-wrap">
|
||||
<text class="custom-currency">¥</text>
|
||||
<input
|
||||
class="custom-input"
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
:value="modelValue"
|
||||
@focus="emit('select')"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<view class="custom-hint">
|
||||
<text>请输入整数金额</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "select"]);
|
||||
const MAX_CUSTOM_AMOUNT = 999999;
|
||||
|
||||
const handleInput = (event) => {
|
||||
const rawValue = event?.detail?.value ?? event?.target?.value ?? "";
|
||||
const amountText = String(rawValue).trim();
|
||||
const value = /^\d*$/.test(amountText)
|
||||
? amountText
|
||||
? String(Math.min(Number(amountText), MAX_CUSTOM_AMOUNT))
|
||||
: ""
|
||||
: props.modelValue;
|
||||
emit("select");
|
||||
emit("update:modelValue", value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
85
src/pages-aigc/recharge/components/RechargeFooter/index.vue
Normal file
85
src/pages-aigc/recharge/components/RechargeFooter/index.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<view class="recharge-footer">
|
||||
<view class="recharge-agreement" :class="{ 'is-disabled': !agreementReady }">
|
||||
<CheckBox :model-value="agreed" @update:model-value="handleAgreementChange">
|
||||
<text class="recharge-agreement-text">我已阅读并同意</text>
|
||||
<text class="recharge-agreement-link" @tap.stop="handleViewAgreement">《积分充值协议》</text>
|
||||
</CheckBox>
|
||||
</view>
|
||||
|
||||
<view class="recharge-payment-row">
|
||||
<text class="recharge-gain">
|
||||
支付金额 <text class="recharge-gain-value">¥{{ formattedPrice }}</text>
|
||||
</text>
|
||||
|
||||
<view
|
||||
class="recharge-pay-button"
|
||||
:class="{ 'is-disabled': payDisabled }"
|
||||
@tap="handlePay"
|
||||
>
|
||||
{{ loading ? "支付中..." : "立即支付" }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import CheckBox from "@/components/CheckBox/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
price: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
agreed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
agreementReady: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["pay", "update:agreed", "view-agreement"]);
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const price = Number(props.price);
|
||||
if (!Number.isFinite(price) || price < 0) return "0.00";
|
||||
|
||||
return price.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
});
|
||||
const payDisabled = computed(() => {
|
||||
return props.loading || props.price <= 0 || !props.agreed || !props.agreementReady;
|
||||
});
|
||||
|
||||
const handleAgreementChange = (value) => {
|
||||
if (!props.agreementReady) {
|
||||
emit("view-agreement");
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:agreed", value);
|
||||
};
|
||||
|
||||
const handleViewAgreement = () => {
|
||||
emit("view-agreement");
|
||||
};
|
||||
|
||||
const handlePay = () => {
|
||||
if (props.loading || props.price <= 0) return;
|
||||
emit("pay");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<view class="package-section">
|
||||
<text class="package-title">优惠充值</text>
|
||||
|
||||
<view class="package-list">
|
||||
<view
|
||||
v-for="item in packages"
|
||||
:key="getPackageKey(item)"
|
||||
:class="['package-card', { 'is-selected': getPackageKey(item) === selectedKey }]"
|
||||
@tap="emit('select', item)"
|
||||
>
|
||||
<view class="package-copy">
|
||||
<text class="package-points">{{ item.rechargePoints }}积分</text>
|
||||
<text class="package-origin">充值金额</text>
|
||||
</view>
|
||||
|
||||
<text class="package-price">¥{{ formatPrice(item.expectedAmountFen) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatFenAmount } from "../../utils/virtualRecharge.js";
|
||||
|
||||
defineProps({
|
||||
packages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedKey: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const formatPrice = (amountFen) => formatFenAmount(amountFen);
|
||||
|
||||
const getPackageKey = (item) => String(item?.optionCode || "");
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
463
src/pages-aigc/recharge/recharge.vue
Normal file
463
src/pages-aigc/recharge/recharge.vue
Normal file
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<view class="aigc-recharge-page">
|
||||
<TopNavBar
|
||||
title="积分充值"
|
||||
title-align="left"
|
||||
background="transparent"
|
||||
title-color="#172033"
|
||||
back-icon-color="#172033"
|
||||
:align-with-menu-button="true"
|
||||
:z-index="3"
|
||||
@back="handleBack"
|
||||
/>
|
||||
|
||||
<view class="aigc-recharge-content">
|
||||
<BalanceCard
|
||||
:points="balancePoints"
|
||||
@ledger="handleOpenLedger"
|
||||
/>
|
||||
|
||||
<RechargePackageList
|
||||
:packages="rechargeOptions"
|
||||
:selected-key="selectedRechargeKey"
|
||||
@select="handleSelectRechargeOption"
|
||||
/>
|
||||
|
||||
<CustomAmountCard
|
||||
v-model="customAmount"
|
||||
:selected="rechargeMode === 'custom'"
|
||||
@select="handleSelectCustomAmount"
|
||||
/>
|
||||
|
||||
<RechargeFooter
|
||||
:price="payPrice"
|
||||
:loading="paying"
|
||||
:agreed="agreementAccepted"
|
||||
:agreement-ready="agreementReady"
|
||||
@update:agreed="agreementAccepted = $event"
|
||||
@view-agreement="handleViewAgreement"
|
||||
@pay="handlePay"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<AgreePopup
|
||||
:visible="agreementVisible"
|
||||
title="积分充值协议"
|
||||
:agreement="agreementContent"
|
||||
@close="agreementVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
|
||||
import AgreePopup from "@/pages/login/components/AgreePopup/index.vue";
|
||||
import { getPointsTopUpAgreement } from "@/request/api/AigcApi.js";
|
||||
import {
|
||||
confirmVirtualRechargeOrder,
|
||||
createVirtualRechargeOrder,
|
||||
getVirtualRechargeOptions,
|
||||
getVirtualRechargeOrder,
|
||||
} from "@/request/api/VirtualRechargeApi.js";
|
||||
import BalanceCard from "./components/BalanceCard/index.vue";
|
||||
import CustomAmountCard from "./components/CustomAmountCard/index.vue";
|
||||
import RechargeFooter from "./components/RechargeFooter/index.vue";
|
||||
import RechargePackageList from "./components/RechargePackageList/index.vue";
|
||||
import {
|
||||
getPendingVirtualRechargeOrderNo,
|
||||
removePendingVirtualRechargeOrderNo,
|
||||
setPendingVirtualRechargeOrderNo,
|
||||
} from "./services/pendingVirtualRecharge.js";
|
||||
import {
|
||||
getWechatClientPlatform,
|
||||
getWechatLoginCode,
|
||||
requestWechatVirtualPayment,
|
||||
} from "./services/wechatVirtualPayment.js";
|
||||
import {
|
||||
buildVirtualRechargeOrderPayload,
|
||||
classifyVirtualRechargeStatus,
|
||||
normalizeVirtualPaymentOrder,
|
||||
VIRTUAL_RECHARGE_STATUS_KIND,
|
||||
} from "./utils/virtualRecharge.js";
|
||||
|
||||
const ORDER_QUERY_INTERVAL = 2000;
|
||||
const MAX_ORDER_QUERY_ATTEMPTS = 5;
|
||||
|
||||
const { pointBalance: balancePoints, fetchCurrentCredit } = useCurrentCredit();
|
||||
const rechargeOptions = ref([]);
|
||||
const selectedRechargeOption = ref(null);
|
||||
const customAmount = ref("");
|
||||
const rechargeMode = ref("package");
|
||||
const paying = ref(false);
|
||||
const reconciling = ref(false);
|
||||
const agreementAccepted = ref(false);
|
||||
const agreementContent = ref("");
|
||||
const agreementLoading = ref(false);
|
||||
const agreementVisible = ref(false);
|
||||
|
||||
let orderQueryTimer = null;
|
||||
let orderQueryResolver = null;
|
||||
let pageVisible = false;
|
||||
|
||||
const agreementReady = computed(() => Boolean(agreementContent.value.trim()));
|
||||
|
||||
const selectedRechargeKey = computed(() => {
|
||||
if (rechargeMode.value !== "package") return "";
|
||||
return String(selectedRechargeOption.value?.optionCode || "");
|
||||
});
|
||||
|
||||
const payPrice = computed(() => {
|
||||
if (rechargeMode.value === "custom") {
|
||||
const amount = Number(customAmount.value);
|
||||
return Number.isInteger(amount) ? amount : 0;
|
||||
}
|
||||
|
||||
const amountFen = Number(selectedRechargeOption.value?.expectedAmountFen);
|
||||
return Number.isInteger(amountFen) && amountFen > 0 ? amountFen / 100 : 0;
|
||||
});
|
||||
|
||||
const showToast = (title, icon = "none") => {
|
||||
uni.showToast({ title, icon });
|
||||
};
|
||||
|
||||
const clearOrderQueryTimer = () => {
|
||||
if (orderQueryTimer) {
|
||||
clearTimeout(orderQueryTimer);
|
||||
orderQueryTimer = null;
|
||||
}
|
||||
if (orderQueryResolver) {
|
||||
orderQueryResolver(false);
|
||||
orderQueryResolver = null;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForNextOrderQuery = () => {
|
||||
if (!pageVisible) return Promise.resolve(false);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
orderQueryResolver = resolve;
|
||||
orderQueryTimer = setTimeout(() => {
|
||||
orderQueryTimer = null;
|
||||
orderQueryResolver = null;
|
||||
resolve(pageVisible);
|
||||
}, ORDER_QUERY_INTERVAL);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchRechargeOptions = async () => {
|
||||
try {
|
||||
const res = await getVirtualRechargeOptions();
|
||||
if (res?.code === 0 && Array.isArray(res.data)) {
|
||||
rechargeOptions.value = res.data;
|
||||
if (rechargeMode.value === "package") {
|
||||
selectedRechargeOption.value = res.data[0] || null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
rechargeOptions.value = [];
|
||||
selectedRechargeOption.value = null;
|
||||
} catch (error) {
|
||||
console.error("获取虚拟支付充值档位失败", error);
|
||||
rechargeOptions.value = [];
|
||||
selectedRechargeOption.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPointsTopUpAgreement = async (showError = false) => {
|
||||
if (agreementLoading.value) return false;
|
||||
|
||||
agreementLoading.value = true;
|
||||
try {
|
||||
const res = await getPointsTopUpAgreement();
|
||||
const content = typeof res?.data === "string" ? res.data.trim() : "";
|
||||
if (res?.code === 0 && content) {
|
||||
agreementContent.value = content;
|
||||
return true;
|
||||
}
|
||||
|
||||
agreementContent.value = "";
|
||||
agreementAccepted.value = false;
|
||||
if (showError) showToast(res?.msg || "协议加载失败,请重试");
|
||||
} catch (error) {
|
||||
console.error("获取积分充值协议失败", error);
|
||||
agreementContent.value = "";
|
||||
agreementAccepted.value = false;
|
||||
if (showError) showToast("协议加载失败,请重试");
|
||||
} finally {
|
||||
agreementLoading.value = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const finishOrderByStatus = async (orderData) => {
|
||||
const statusKind = classifyVirtualRechargeStatus(orderData?.status);
|
||||
|
||||
if (statusKind === VIRTUAL_RECHARGE_STATUS_KIND.PAID) {
|
||||
removePendingVirtualRechargeOrderNo();
|
||||
await fetchCurrentCredit();
|
||||
showToast("充值成功", "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (statusKind === VIRTUAL_RECHARGE_STATUS_KIND.REFUNDED) {
|
||||
removePendingVirtualRechargeOrderNo();
|
||||
await fetchCurrentCredit();
|
||||
showToast("充值订单已退款");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (statusKind === VIRTUAL_RECHARGE_STATUS_KIND.FAILED) {
|
||||
removePendingVirtualRechargeOrderNo();
|
||||
showToast("充值订单异常,请重新发起支付");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getPaymentErrorMessage = (error) => {
|
||||
switch (Number(error?.errCode)) {
|
||||
case -2:
|
||||
return "已取消支付";
|
||||
case -4:
|
||||
return "支付被微信风控拦截";
|
||||
case -15007:
|
||||
return "微信登录状态已过期,请重新发起支付";
|
||||
default:
|
||||
return "支付失败,请重试";
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileVirtualRechargeOrder = async (
|
||||
rechargeOrderNo,
|
||||
{ clientPaymentError = null, showPendingToast = true } = {}
|
||||
) => {
|
||||
if (reconciling.value || !rechargeOrderNo) return "skipped";
|
||||
|
||||
reconciling.value = true;
|
||||
let latestOrder = null;
|
||||
|
||||
try {
|
||||
let confirmArgs = {};
|
||||
try {
|
||||
const wxLoginCode = await getWechatLoginCode();
|
||||
confirmArgs = { wxLoginCode };
|
||||
} catch (error) {
|
||||
console.warn("刷新微信登录凭证失败,将使用现有服务端会话确认订单", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const confirmRes = await confirmVirtualRechargeOrder(
|
||||
rechargeOrderNo,
|
||||
confirmArgs
|
||||
);
|
||||
if (confirmRes?.code === 0 && confirmRes.data) {
|
||||
latestOrder = confirmRes.data;
|
||||
if (await finishOrderByStatus(latestOrder)) return "terminal";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("服务端确认虚拟支付订单失败", error);
|
||||
}
|
||||
|
||||
if (Number(clientPaymentError?.errCode) === -2 && latestOrder?.status === "UNPAID") {
|
||||
removePendingVirtualRechargeOrderNo();
|
||||
showToast("已取消支付");
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ORDER_QUERY_ATTEMPTS; attempt += 1) {
|
||||
const shouldContinue = await waitForNextOrderQuery();
|
||||
if (!shouldContinue) return "paused";
|
||||
|
||||
try {
|
||||
const queryRes = await getVirtualRechargeOrder(rechargeOrderNo);
|
||||
if (queryRes?.code === 0 && queryRes.data) {
|
||||
latestOrder = queryRes.data;
|
||||
if (await finishOrderByStatus(latestOrder)) return "terminal";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询虚拟支付充值订单失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (clientPaymentError && latestOrder?.status === "UNPAID") {
|
||||
removePendingVirtualRechargeOrderNo();
|
||||
showToast(getPaymentErrorMessage(clientPaymentError));
|
||||
return "client-failed";
|
||||
}
|
||||
|
||||
if (showPendingToast) {
|
||||
showToast("支付结果确认中,请稍后返回查看");
|
||||
}
|
||||
return "pending";
|
||||
} finally {
|
||||
reconciling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const recoverPendingVirtualRechargeOrder = async () => {
|
||||
const rechargeOrderNo = getPendingVirtualRechargeOrderNo();
|
||||
if (!rechargeOrderNo || paying.value || reconciling.value) return;
|
||||
|
||||
paying.value = true;
|
||||
uni.showLoading({ title: "正在确认支付结果...", mask: true });
|
||||
try {
|
||||
await reconcileVirtualRechargeOrder(rechargeOrderNo);
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
paying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) uni.navigateBack();
|
||||
};
|
||||
|
||||
const handleOpenLedger = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages-aigc/pointsDetails/pointsDetails",
|
||||
fail: () => showToast("积分明细"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectRechargeOption = (option) => {
|
||||
rechargeMode.value = "package";
|
||||
selectedRechargeOption.value = option;
|
||||
customAmount.value = "";
|
||||
};
|
||||
|
||||
const handleSelectCustomAmount = () => {
|
||||
rechargeMode.value = "custom";
|
||||
selectedRechargeOption.value = null;
|
||||
};
|
||||
|
||||
const handleViewAgreement = async () => {
|
||||
if (agreementReady.value) {
|
||||
agreementVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (agreementLoading.value) {
|
||||
showToast("协议加载中,请稍候");
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = await fetchPointsTopUpAgreement(true);
|
||||
if (loaded) agreementVisible.value = true;
|
||||
};
|
||||
|
||||
const validateRechargeSelection = () => {
|
||||
if (rechargeMode.value === "package") {
|
||||
if (!String(selectedRechargeOption.value?.optionCode || "").trim()) {
|
||||
throw new Error("请选择充值档位");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const amountText = String(customAmount.value || "").trim();
|
||||
const amount = Number(amountText);
|
||||
if (!/^\d{1,6}$/.test(amountText) || amount < 1 || amount > 999999) {
|
||||
throw new Error("请输入1至999999的整数充值金额");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
if (paying.value) return;
|
||||
|
||||
try {
|
||||
validateRechargeSelection();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (agreementLoading.value) {
|
||||
showToast("协议加载中,请稍候");
|
||||
return;
|
||||
}
|
||||
if (!agreementReady.value) {
|
||||
showToast("协议加载失败,请重试");
|
||||
return;
|
||||
}
|
||||
if (!agreementAccepted.value) {
|
||||
showToast("请先阅读并同意积分充值协议");
|
||||
return;
|
||||
}
|
||||
|
||||
paying.value = true;
|
||||
clearOrderQueryTimer();
|
||||
uni.showLoading({ title: "正在发起支付...", mask: true });
|
||||
|
||||
try {
|
||||
const clientPlatform = getWechatClientPlatform();
|
||||
const wxLoginCode = await getWechatLoginCode();
|
||||
const createOrderPayload = buildVirtualRechargeOrderPayload({
|
||||
mode: rechargeMode.value,
|
||||
rechargeOptionCode: selectedRechargeOption.value?.optionCode,
|
||||
rechargeAmount: customAmount.value,
|
||||
wxLoginCode,
|
||||
clientPlatform,
|
||||
});
|
||||
const createRes = await createVirtualRechargeOrder(createOrderPayload);
|
||||
|
||||
if (createRes?.code !== 0 || !createRes.data) {
|
||||
throw new Error(createRes?.msg || "充值下单失败,请重试");
|
||||
}
|
||||
|
||||
const paymentOrder = normalizeVirtualPaymentOrder(createRes.data);
|
||||
setPendingVirtualRechargeOrderNo(paymentOrder.rechargeOrderNo);
|
||||
uni.hideLoading();
|
||||
|
||||
let clientPaymentError = null;
|
||||
try {
|
||||
await requestWechatVirtualPayment(paymentOrder);
|
||||
} catch (error) {
|
||||
clientPaymentError = error;
|
||||
}
|
||||
|
||||
pageVisible = true;
|
||||
uni.showLoading({ title: "正在确认支付结果...", mask: true });
|
||||
await reconcileVirtualRechargeOrder(paymentOrder.rechargeOrderNo, {
|
||||
clientPaymentError,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("虚拟支付积分充值失败", error);
|
||||
showToast(error?.message || "充值下单失败,请重试");
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
paying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
agreementAccepted.value = false;
|
||||
fetchRechargeOptions();
|
||||
fetchPointsTopUpAgreement();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
pageVisible = true;
|
||||
fetchCurrentCredit();
|
||||
recoverPendingVirtualRechargeOrder();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
pageVisible = false;
|
||||
clearOrderQueryTimer();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageVisible = false;
|
||||
clearOrderQueryTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
26
src/pages-aigc/recharge/services/pendingVirtualRecharge.js
Normal file
26
src/pages-aigc/recharge/services/pendingVirtualRecharge.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { currentClientType } from "@/constant/base.js";
|
||||
|
||||
const PENDING_VIRTUAL_RECHARGE_ORDER_KEY = `${currentClientType()}_AIGC_PENDING_VIRTUAL_RECHARGE_ORDER`;
|
||||
|
||||
const getStorageApi = () => (typeof uni !== "undefined" ? uni : null);
|
||||
|
||||
export function getPendingVirtualRechargeOrderNo(storage = getStorageApi()) {
|
||||
const orderNo = storage?.getStorageSync?.(PENDING_VIRTUAL_RECHARGE_ORDER_KEY);
|
||||
return typeof orderNo === "string" ? orderNo.trim() : "";
|
||||
}
|
||||
|
||||
export function setPendingVirtualRechargeOrderNo(orderNo, storage = getStorageApi()) {
|
||||
const normalizedOrderNo = String(orderNo || "").trim();
|
||||
if (!normalizedOrderNo) {
|
||||
throw new Error("待确认充值订单号不能为空");
|
||||
}
|
||||
|
||||
return storage?.setStorageSync?.(
|
||||
PENDING_VIRTUAL_RECHARGE_ORDER_KEY,
|
||||
normalizedOrderNo
|
||||
);
|
||||
}
|
||||
|
||||
export function removePendingVirtualRechargeOrderNo(storage = getStorageApi()) {
|
||||
return storage?.removeStorageSync?.(PENDING_VIRTUAL_RECHARGE_ORDER_KEY);
|
||||
}
|
||||
102
src/pages-aigc/recharge/services/wechatVirtualPayment.js
Normal file
102
src/pages-aigc/recharge/services/wechatVirtualPayment.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
normalizeVirtualPaymentOrder,
|
||||
normalizeWechatClientPlatform,
|
||||
} from "../utils/virtualRecharge.js";
|
||||
|
||||
const MIN_VIRTUAL_PAYMENT_SDK_VERSION = "2.19.2";
|
||||
|
||||
const getWechatApi = () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== "undefined") return wx;
|
||||
// #endif
|
||||
return null;
|
||||
};
|
||||
|
||||
export function compareVersion(versionA, versionB) {
|
||||
const versionsA = String(versionA || "").split(".");
|
||||
const versionsB = String(versionB || "").split(".");
|
||||
const length = Math.max(versionsA.length, versionsB.length);
|
||||
|
||||
while (versionsA.length < length) versionsA.push("0");
|
||||
while (versionsB.length < length) versionsB.push("0");
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const numberA = Number.parseInt(versionsA[index], 10) || 0;
|
||||
const numberB = Number.parseInt(versionsB[index], 10) || 0;
|
||||
if (numberA > numberB) return 1;
|
||||
if (numberA < numberB) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function assertWechatVirtualPaymentCapability(wechatApi = getWechatApi()) {
|
||||
if (!wechatApi) {
|
||||
throw new Error("当前环境不支持微信虚拟支付");
|
||||
}
|
||||
|
||||
const appBaseInfo =
|
||||
typeof wechatApi.getAppBaseInfo === "function"
|
||||
? wechatApi.getAppBaseInfo()
|
||||
: wechatApi.getSystemInfoSync?.() || {};
|
||||
const versionSupported =
|
||||
compareVersion(appBaseInfo?.SDKVersion, MIN_VIRTUAL_PAYMENT_SDK_VERSION) >= 0;
|
||||
const apiSupported =
|
||||
typeof wechatApi.requestVirtualPayment === "function" &&
|
||||
(versionSupported || wechatApi.canIUse?.("requestVirtualPayment"));
|
||||
|
||||
if (!apiSupported) {
|
||||
throw new Error("当前微信版本不支持微信虚拟支付");
|
||||
}
|
||||
}
|
||||
|
||||
export function getWechatClientPlatform(wechatApi = getWechatApi()) {
|
||||
assertWechatVirtualPaymentCapability(wechatApi);
|
||||
const deviceInfo =
|
||||
typeof wechatApi.getDeviceInfo === "function"
|
||||
? wechatApi.getDeviceInfo()
|
||||
: wechatApi.getSystemInfoSync?.() || {};
|
||||
const clientPlatform = normalizeWechatClientPlatform(deviceInfo?.platform);
|
||||
|
||||
if (!clientPlatform) {
|
||||
throw new Error("当前客户端平台不支持微信虚拟支付");
|
||||
}
|
||||
|
||||
return clientPlatform;
|
||||
}
|
||||
|
||||
export function getWechatLoginCode(wechatApi = getWechatApi()) {
|
||||
if (!wechatApi || typeof wechatApi.login !== "function") {
|
||||
return Promise.reject(new Error("当前环境无法获取微信登录凭证"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wechatApi.login({
|
||||
success: (result) => {
|
||||
const code = String(result?.code || "").trim();
|
||||
if (code) {
|
||||
resolve(code);
|
||||
return;
|
||||
}
|
||||
reject(new Error("微信登录凭证获取失败"));
|
||||
},
|
||||
fail: () => reject(new Error("微信登录凭证获取失败")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function requestWechatVirtualPayment(paymentData, wechatApi = getWechatApi()) {
|
||||
assertWechatVirtualPaymentCapability(wechatApi);
|
||||
const order = normalizeVirtualPaymentOrder(paymentData);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wechatApi.requestVirtualPayment({
|
||||
signData: order.signData,
|
||||
paySig: order.paySig,
|
||||
signature: order.signature,
|
||||
mode: order.mode,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
136
src/pages-aigc/recharge/utils/virtualRecharge.js
Normal file
136
src/pages-aigc/recharge/utils/virtualRecharge.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const ALLOWED_CLIENT_PLATFORMS = new Set([
|
||||
"ANDROID",
|
||||
"HARMONY",
|
||||
"WINDOWS",
|
||||
"IOS",
|
||||
]);
|
||||
|
||||
const REQUIRED_PAYMENT_ORDER_FIELDS = [
|
||||
"rechargeOrderNo",
|
||||
"paymentOrderNo",
|
||||
"signData",
|
||||
"paySig",
|
||||
"signature",
|
||||
];
|
||||
|
||||
export const VIRTUAL_RECHARGE_STATUS_KIND = Object.freeze({
|
||||
PAID: "paid",
|
||||
REFUNDED: "refunded",
|
||||
FAILED: "failed",
|
||||
PENDING: "pending",
|
||||
UNKNOWN: "unknown",
|
||||
});
|
||||
|
||||
export function normalizeWechatClientPlatform(platform) {
|
||||
const normalizedPlatform = String(platform || "").toLowerCase();
|
||||
const platformMap = {
|
||||
android: "ANDROID",
|
||||
ios: "IOS",
|
||||
ohos: "HARMONY",
|
||||
ohos_pc: "HARMONY",
|
||||
windows: "WINDOWS",
|
||||
};
|
||||
|
||||
return platformMap[normalizedPlatform] || "";
|
||||
}
|
||||
|
||||
export function buildVirtualRechargeOrderPayload({
|
||||
mode,
|
||||
rechargeOptionCode,
|
||||
rechargeAmount,
|
||||
wxLoginCode,
|
||||
clientPlatform,
|
||||
} = {}) {
|
||||
const normalizedLoginCode = String(wxLoginCode || "").trim();
|
||||
const normalizedPlatform = String(clientPlatform || "").trim().toUpperCase();
|
||||
|
||||
if (!normalizedLoginCode) {
|
||||
throw new Error("微信登录凭证无效");
|
||||
}
|
||||
|
||||
if (!ALLOWED_CLIENT_PLATFORMS.has(normalizedPlatform)) {
|
||||
throw new Error("当前客户端平台不支持微信虚拟支付");
|
||||
}
|
||||
|
||||
const commonPayload = {
|
||||
wxLoginCode: normalizedLoginCode,
|
||||
clientPlatform: normalizedPlatform,
|
||||
};
|
||||
|
||||
if (mode === "package") {
|
||||
const normalizedOptionCode = String(rechargeOptionCode || "").trim();
|
||||
if (!normalizedOptionCode) {
|
||||
throw new Error("请选择充值档位");
|
||||
}
|
||||
|
||||
return {
|
||||
rechargeOptionCode: normalizedOptionCode,
|
||||
...commonPayload,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "custom") {
|
||||
const amountText = String(rechargeAmount ?? "").trim();
|
||||
if (!/^\d{1,6}$/.test(amountText)) {
|
||||
throw new Error("充值金额必须为1至999999的整数");
|
||||
}
|
||||
|
||||
const normalizedAmount = Number(amountText);
|
||||
if (normalizedAmount < 1 || normalizedAmount > 999999) {
|
||||
throw new Error("充值金额必须为1至999999的整数");
|
||||
}
|
||||
|
||||
return {
|
||||
rechargeAmount: normalizedAmount,
|
||||
...commonPayload,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("请选择充值方式");
|
||||
}
|
||||
|
||||
export function normalizeVirtualPaymentOrder(orderData) {
|
||||
const normalizedOrder = orderData && typeof orderData === "object" ? orderData : {};
|
||||
const hasRequiredFields = REQUIRED_PAYMENT_ORDER_FIELDS.every((field) => {
|
||||
return typeof normalizedOrder[field] === "string" && normalizedOrder[field].trim();
|
||||
});
|
||||
|
||||
if (!hasRequiredFields || normalizedOrder.mode !== "short_series_coin") {
|
||||
throw new Error("虚拟支付订单参数错误");
|
||||
}
|
||||
|
||||
return {
|
||||
rechargeOrderNo: normalizedOrder.rechargeOrderNo,
|
||||
paymentOrderNo: normalizedOrder.paymentOrderNo,
|
||||
mode: normalizedOrder.mode,
|
||||
signData: normalizedOrder.signData,
|
||||
paySig: normalizedOrder.paySig,
|
||||
signature: normalizedOrder.signature,
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyVirtualRechargeStatus(status) {
|
||||
switch (status) {
|
||||
case "PAID":
|
||||
return VIRTUAL_RECHARGE_STATUS_KIND.PAID;
|
||||
case "REFUNDED":
|
||||
return VIRTUAL_RECHARGE_STATUS_KIND.REFUNDED;
|
||||
case "CREATE_FAILED":
|
||||
case "PAYMENT_ABNORMAL":
|
||||
return VIRTUAL_RECHARGE_STATUS_KIND.FAILED;
|
||||
case "CREATING":
|
||||
case "UNPAID":
|
||||
return VIRTUAL_RECHARGE_STATUS_KIND.PENDING;
|
||||
default:
|
||||
return VIRTUAL_RECHARGE_STATUS_KIND.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFenAmount(amountFen) {
|
||||
const normalizedAmount = Number(amountFen);
|
||||
if (!Number.isInteger(normalizedAmount) || normalizedAmount < 0) {
|
||||
return "0.00";
|
||||
}
|
||||
|
||||
return (normalizedAmount / 100).toFixed(2);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB |
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<uni-popup
|
||||
ref="popupRef"
|
||||
type="bottom"
|
||||
background-color="transparent"
|
||||
mask-background-color="rgba(0, 0, 0, 0)"
|
||||
:safe-area="false"
|
||||
:is-mask-click="false"
|
||||
>
|
||||
<uni-popup ref="popupRef" type="bottom" background-color="transparent" mask-background-color="rgba(0, 0, 0, 0)"
|
||||
:safe-area="false" :is-mask-click="false">
|
||||
<view class="photo-confirm-drawer">
|
||||
<view class="photo-confirm-close" @tap="emit('close')">
|
||||
<uni-icons type="closeempty" size="30" color="#8fa2ba" />
|
||||
@@ -33,12 +27,11 @@
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import defaultAvatar from "../../assets/xiaoqi-avatar.png";
|
||||
|
||||
defineProps({
|
||||
avatarSrc: {
|
||||
type: String,
|
||||
default: defaultAvatar,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<view class="aigc-use-template-page">
|
||||
<AigcTopBar :points="pointBalance" @back="handleBack" @history="handleHistory" />
|
||||
<AigcTopBar :points="pointBalance" recharge-label="充值" @back="handleBack" @history="handleHistory"
|
||||
@recharge="handleRecharge" />
|
||||
|
||||
<view class="aigc-use-template-body">
|
||||
<view class="aigc-use-template-content">
|
||||
<TemplateVersionHero :template="currentTemplate" />
|
||||
|
||||
<GenerateConfirmPanel v-if="currentStep === 'generateConfirm'" :cost="currentCost"
|
||||
:balance="pointBalance" @generate="handleGenerate" />
|
||||
<GenerateConfirmPanel v-if="currentStep === 'generateConfirm'" :cost="currentCost" :balance="pointBalance"
|
||||
@generate="handleGenerate" @recharge="handleRecharge" />
|
||||
|
||||
<template v-else>
|
||||
<VersionOptionList :options="templateItems" :selected-value="selectedTemplateItemId"
|
||||
@@ -29,11 +30,11 @@
|
||||
@album="handlePickAlbum" />
|
||||
</view>
|
||||
<view v-if="currentStep === 'photoConfirm'" class="aigc-use-template-popup-host">
|
||||
<PhotoConfirmDrawer :avatar-src="selectedImageLocalPath || guideAvatar" @close="handleClosePhotoConfirm"
|
||||
<PhotoConfirmDrawer :avatar-src="selectedImageLocalPath" @close="handleClosePhotoConfirm"
|
||||
@confirm="handleConfirmPhoto" />
|
||||
</view>
|
||||
<PointInsufficientDialog v-if="pointDialogVisible" :cost="currentCost" :balance="pointBalance"
|
||||
@cancel="handleClosePointDialog" />
|
||||
@cancel="handleClosePointDialog" @recharge="handlePointRecharge" />
|
||||
<Privacy :visible="privacyVisible" :contract-name="privacyContractName" @agree="handlePrivacyAgree"
|
||||
@disagree="handlePrivacyDisagree" />
|
||||
</view>
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
getAigcTemplateList,
|
||||
} from "@/request/api/AigcApi.js";
|
||||
import { updateImageFile } from "@/request/api/UpdateFile.js";
|
||||
import guideAvatar from "./assets/xiaoqi-avatar.png";
|
||||
import GenerateConfirmPanel from "./components/GenerateConfirmPanel/index.vue";
|
||||
import PhotoConfirmDrawer from "./components/PhotoConfirmDrawer/index.vue";
|
||||
import PhotoGuidePanel from "./components/PhotoGuidePanel/index.vue";
|
||||
@@ -164,6 +164,13 @@ const handleHistory = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRecharge = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages-aigc/recharge/recharge",
|
||||
fail: () => showPlaceholderToast("积分充值"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectTemplateItem = (option) => {
|
||||
if (!option?.templateItemId) return;
|
||||
|
||||
@@ -377,6 +384,10 @@ const handleClosePointDialog = () => {
|
||||
pointDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const handlePointRecharge = () => {
|
||||
pointDialogVisible.value = false;
|
||||
handleRecharge();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
44
src/request/api/VirtualRechargeApi.js
Normal file
44
src/request/api/VirtualRechargeApi.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import request from "../base/request";
|
||||
|
||||
const VIRTUAL_RECHARGE_BASE_PATH = "/hotelBiz/credit/virtual-recharge";
|
||||
|
||||
const getVirtualRechargeOrderPath = (rechargeOrderNo) => {
|
||||
const normalizedOrderNo = String(rechargeOrderNo || "").trim();
|
||||
if (!normalizedOrderNo) {
|
||||
throw new Error("积分充值订单号不能为空");
|
||||
}
|
||||
|
||||
return `${VIRTUAL_RECHARGE_BASE_PATH}/orders/${encodeURIComponent(normalizedOrderNo)}`;
|
||||
};
|
||||
|
||||
function getVirtualRechargeOptions() {
|
||||
return request.get(`${VIRTUAL_RECHARGE_BASE_PATH}/options`, {});
|
||||
}
|
||||
|
||||
function createVirtualRechargeOrder(args) {
|
||||
return request.post(`${VIRTUAL_RECHARGE_BASE_PATH}/orders`, args, {
|
||||
sensitive: true,
|
||||
});
|
||||
}
|
||||
|
||||
function confirmVirtualRechargeOrder(rechargeOrderNo, args = {}) {
|
||||
const wxLoginCode = String(args?.wxLoginCode || "").trim();
|
||||
const payload = wxLoginCode ? { wxLoginCode } : {};
|
||||
|
||||
return request.post(
|
||||
`${getVirtualRechargeOrderPath(rechargeOrderNo)}/confirm`,
|
||||
payload,
|
||||
{ sensitive: true }
|
||||
);
|
||||
}
|
||||
|
||||
function getVirtualRechargeOrder(rechargeOrderNo) {
|
||||
return request.get(getVirtualRechargeOrderPath(rechargeOrderNo), {});
|
||||
}
|
||||
|
||||
export {
|
||||
confirmVirtualRechargeOrder,
|
||||
createVirtualRechargeOrder,
|
||||
getVirtualRechargeOptions,
|
||||
getVirtualRechargeOrder,
|
||||
};
|
||||
@@ -14,6 +14,8 @@ const defaultConfig = {
|
||||
};
|
||||
|
||||
function request(url, args = {}, method = "POST", customConfig = {}) {
|
||||
const { sensitive = false, ...requestCustomConfig } = customConfig;
|
||||
customConfig = requestCustomConfig;
|
||||
const appStore = useAppStore();
|
||||
// 判断 url 是否以 http 开头
|
||||
if (!/^http/.test(url)) {
|
||||
@@ -53,7 +55,11 @@ function request(url, args = {}, method = "POST", customConfig = {}) {
|
||||
header,
|
||||
};
|
||||
|
||||
console.log(`\n\n请求接口: ${url}, \n请求参数: ${JSON.stringify(args)}, \n请求头: ${JSON.stringify(config)}\n\n`);
|
||||
if (sensitive) {
|
||||
console.log(`\n\n请求接口: ${url}, \n敏感请求参数和请求头已隐藏\n\n`);
|
||||
} else {
|
||||
console.log(`\n\n请求接口: ${url}, \n请求参数: ${JSON.stringify(args)}, \n请求头: ${JSON.stringify(config)}\n\n`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
@@ -62,7 +68,11 @@ function request(url, args = {}, method = "POST", customConfig = {}) {
|
||||
method,
|
||||
...config,
|
||||
success: (res) => {
|
||||
console.log(`\n\n请求接口: ${url}, \n请求响应: ${JSON.stringify(res)}, \n\n`);
|
||||
if (sensitive) {
|
||||
console.log(`\n\n请求接口: ${url}, \n响应状态: ${res.statusCode || "unknown"}\n\n`);
|
||||
} else {
|
||||
console.log(`\n\n请求接口: ${url}, \n请求响应: ${JSON.stringify(res)}, \n\n`);
|
||||
}
|
||||
|
||||
resolve(res.data);
|
||||
if (res.statusCode && res.statusCode === 424) {
|
||||
|
||||
@@ -23,14 +23,6 @@ export default defineConfig({
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames(d) {
|
||||
const baseName = d.name.replace(/\\/g, "/").split("/").pop();
|
||||
const newName = md5(baseName) + ".[hash].[extname]";
|
||||
return `assets/${newName}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
rollupOptions: {},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user