Compare commits
11 Commits
ab89163685
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 80e879a08c | |||
| ebb0ae76bb | |||
| 0b5f8293e5 | |||
| ad6fb13ec1 | |||
| 2fd6f073c3 | |||
| 4507cd484e | |||
| d87e9d3f13 | |||
| 43f5bee5b1 | |||
| 28102fb0bd | |||
| 54fa9e772c | |||
| 6dec400b0e |
@@ -11,7 +11,29 @@
|
|||||||
"usedAt": 1775579277895,
|
"usedAt": 1775579277895,
|
||||||
"industryId": "all"
|
"industryId": "all"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"c02960235460481c84382169b26bf90c": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775818515062,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"5bc2686c5a3a4f6d98f74a1467d05be8": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775866025894,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lastUpdated": 1775579765675
|
"lastUpdated": 1775868406858
|
||||||
}
|
}
|
||||||
42
.workbuddy/memory/2026-04-08.md
Normal file
42
.workbuddy/memory/2026-04-08.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 2026-04-08 工作记录
|
||||||
|
|
||||||
|
## passport/qr-confirm/index.tsx 页面改造
|
||||||
|
|
||||||
|
### 改造内容
|
||||||
|
将扫码登录确认页面的授权登录界面改造成参考权大师的设计风格:
|
||||||
|
|
||||||
|
1. **UI 改造**
|
||||||
|
- 顶部标题"请登录"
|
||||||
|
- 中间 Logo + 品牌名 + 标语(权大师风格)
|
||||||
|
- 橙色主按钮"手机号授权登录"
|
||||||
|
- 灰色"取消"按钮
|
||||||
|
- 底部协议勾选(带链接可点击查看详情)
|
||||||
|
|
||||||
|
2. **新增功能**
|
||||||
|
- 协议勾选状态管理
|
||||||
|
- 服务协议和隐私政策弹窗
|
||||||
|
- 未勾选协议时点击授权按钮会提示
|
||||||
|
|
||||||
|
3. **保留功能**
|
||||||
|
- 已注册用户的自动登录确认流程
|
||||||
|
- 未注册用户的手机号授权登录
|
||||||
|
- 扫码登录成功后的跳转逻辑
|
||||||
|
|
||||||
|
### 页面流程
|
||||||
|
```
|
||||||
|
用户扫码 → qr-confirm 页面
|
||||||
|
↓
|
||||||
|
检测用户状态
|
||||||
|
↓
|
||||||
|
┌──────────────┬──────────────┐
|
||||||
|
已注册 未注册
|
||||||
|
↓ ↓
|
||||||
|
自动确认登录 显示授权登录界面
|
||||||
|
↓ ↓
|
||||||
|
登录成功 一键授权登录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术要点
|
||||||
|
- 使用 Taro 的 Button 组件 openType="getPhoneNumber" 获取微信手机号授权
|
||||||
|
- 授权前校验协议勾选状态
|
||||||
|
- 调用后端 loginByMpWxPhone 接口(带 notVerifyPhone: true 自动注册)
|
||||||
61
.workbuddy/memory/2026-04-10.md
Normal file
61
.workbuddy/memory/2026-04-10.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 2026-04-10 工作记录
|
||||||
|
|
||||||
|
## qr-confirm 登录页优化
|
||||||
|
|
||||||
|
- 修改文件:`src/passport/qr-confirm/index.tsx`
|
||||||
|
- 授权按钮改为全宽 52px 圆角大按钮(inline style),易于点击
|
||||||
|
- 勾选框外层包裹 padding:8px 的热区 View,点击更容易命中
|
||||||
|
- 协议从内嵌弹窗改为跳转 WebView 页面打开网页版:
|
||||||
|
- 服务协议:https://websopy.websoft.top/agreement
|
||||||
|
- 隐私政策:https://websopy.websoft.top/privacy
|
||||||
|
- 新增通用 WebView 页面:`src/passport/webview/index.tsx`
|
||||||
|
- `src/app.config.ts` passport 子包新增 `webview/index` 路由
|
||||||
|
|
||||||
|
## qr-confirm 按钮颜色修复
|
||||||
|
|
||||||
|
- 问题:NutUI `Button` 组件内置样式优先级高,覆盖了 `style` 中的 `background` 渐变色,按钮颜色不显示
|
||||||
|
- 修复:改用小程序原生 `button` 标签,`open-type="getPhoneNumber"` 照常有效,渐变背景正常显示
|
||||||
|
|
||||||
|
## template-5 vs PC端 功能对比分析
|
||||||
|
|
||||||
|
- 对比了三个项目:template-5(小程序) vs websopy-pc(PC端/Nuxt3) vs websopy-java(后端)
|
||||||
|
- PC端定位:SaaS平台门户 + 用户控制台 + 开发者中心 + 管理后台
|
||||||
|
- 小程序端定位:移动端入口 + 电商购物(首页偏营销展示)
|
||||||
|
- **关键发现**:
|
||||||
|
- Bug: UserGrid.tsx 开发者中心入口跳转到 /rider/index(骑手),应修正
|
||||||
|
- 缺失核心功能:通知中心、工单系统、应用/产品订阅管理
|
||||||
|
- 购买断链:产品展示后无法在小程序完成购买
|
||||||
|
- admin 模块为空壳,rider 功能极简
|
||||||
|
- PC端开发者专属功能(CI/CD/Git/源码)建议通过 WebView 桥接
|
||||||
|
- 输出完整修改计划报告(4阶段,预估7-12天)
|
||||||
|
|
||||||
|
## 小程序端阶段一+二修改执行
|
||||||
|
|
||||||
|
### Bug 修复 + 代码清理
|
||||||
|
- **UserGrid.tsx**:修复"开发者中心"跳转到 /rider/index 的 Bug,改为通过 WebView 加载 PC 端开发者中心
|
||||||
|
- **UserGrid.tsx**:清理全部注释代码(约60行),代码整洁度大幅提升
|
||||||
|
- **admin/index.tsx**:从空壳"待开发"改为内容管理入口页,保留文章管理功能
|
||||||
|
|
||||||
|
### 骑手中心优化
|
||||||
|
- **rider/index.tsx**:增加今日统计概览(待取货/配送中/已完成/今日任务)
|
||||||
|
- 增加快捷功能入口、待取货/配送中数量徽标、刷新按钮、空状态展示
|
||||||
|
|
||||||
|
### 新增通知中心
|
||||||
|
- API层:`src/api/app/notification/model/index.ts` + `src/api/app/notification/index.ts`
|
||||||
|
- 页面:`src/user/notification/index.tsx` - 支持分页加载、类型标签、已读标记、全部已读、空状态
|
||||||
|
|
||||||
|
### 新增工单系统(3个页面)
|
||||||
|
- API层:`src/api/app/ticket/model/index.ts` + `src/api/app/ticket/index.ts`
|
||||||
|
- 列表页:`src/user/ticket/index.tsx` - 统计概览、状态筛选、分页加载
|
||||||
|
- 提交页:`src/user/ticket/create.tsx` - 标题/分类/优先级/描述表单
|
||||||
|
- 详情页:`src/user/ticket/detail.tsx` - 查看详情、回复、关闭工单
|
||||||
|
|
||||||
|
### 新增我的应用页面
|
||||||
|
- API层:`src/api/app/appProduct/model/index.ts` + `src/api/app/appProduct/index.ts`
|
||||||
|
- 页面:`src/user/apps/index.tsx` - 应用列表、状态展示、管理后台/前台入口、到期时间
|
||||||
|
|
||||||
|
### UserGrid 新增入口
|
||||||
|
- 我的应用、我的工单、消息通知(3个新图标入口)
|
||||||
|
|
||||||
|
### 路由注册
|
||||||
|
- `app.config.ts` user 子包新增:notification/index, ticket/index, ticket/create, ticket/detail, apps/index
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "_auto_c02960235460481c84382169b26bf90c",
|
||||||
|
"leadAgentId": "c02960235460481c84382169b26bf90c",
|
||||||
|
"workspacePath": "/Users/gxwebsoft/VUE/template-5",
|
||||||
|
"createdAt": "2026-04-10T10:58:09.075Z",
|
||||||
|
"options": {
|
||||||
|
"workspacePath": "/Users/gxwebsoft/VUE/template-5",
|
||||||
|
"isAutoTeam": true
|
||||||
|
},
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"memberId": "team-lead@_auto_c02960235460481c84382169b26bf90c",
|
||||||
|
"name": "team-lead",
|
||||||
|
"role": "Team Lead - coordinates the team and interacts with the user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"memberId": "miniapp-explorer@_auto_c02960235460481c84382169b26bf90c",
|
||||||
|
"name": "miniapp-explorer",
|
||||||
|
"role": "分析小程序端功能模块"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"memberId": "pc-explorer@_auto_c02960235460481c84382169b26bf90c",
|
||||||
|
"name": "pc-explorer",
|
||||||
|
"role": "分析PC端功能模块"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"memberId": "java-explorer@_auto_c02960235460481c84382169b26bf90c",
|
||||||
|
"name": "java-explorer",
|
||||||
|
"role": "分析Java后端API接口"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isAutoTeam": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "msg-1775818705614-am57es",
|
||||||
|
"from": "team-lead",
|
||||||
|
"to": "java-explorer",
|
||||||
|
"type": "message",
|
||||||
|
"content": "请继续执行你的分析任务,完成后把结果发给我。",
|
||||||
|
"timestamp": "2026-04-10T10:58:25.614Z",
|
||||||
|
"read": true
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "msg-1775818702473-ypuevm",
|
||||||
|
"from": "team-lead",
|
||||||
|
"to": "miniapp-explorer",
|
||||||
|
"type": "message",
|
||||||
|
"content": "请继续执行你的分析任务,完成后把结果发给我。",
|
||||||
|
"timestamp": "2026-04-10T10:58:22.473Z",
|
||||||
|
"read": true
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "msg-1775818704022-ii1chz",
|
||||||
|
"from": "team-lead",
|
||||||
|
"to": "pc-explorer",
|
||||||
|
"type": "message",
|
||||||
|
"content": "请继续执行你的分析任务,完成后把结果发给我。",
|
||||||
|
"timestamp": "2026-04-10T10:58:24.022Z",
|
||||||
|
"read": true
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,34 +1,48 @@
|
|||||||
import {useEffect} from 'react'
|
import { View, Text } from '@tarojs/components'
|
||||||
import {useUser} from "@/hooks/useUser";
|
import Taro from '@tarojs/taro'
|
||||||
import {Empty} from '@nutui/nutui-react-taro';
|
import { ConfigProvider, Grid } from '@nutui/nutui-react-taro'
|
||||||
import {Text} from '@tarojs/components';
|
import { useUser } from '@/hooks/useUser'
|
||||||
|
import { useThemeStyles } from '@/hooks/useTheme'
|
||||||
|
import { Edit } from '@nutui/icons-react-taro'
|
||||||
|
|
||||||
function Admin() {
|
function Admin() {
|
||||||
const {
|
const { isAdmin } = useUser()
|
||||||
isAdmin
|
const themeStyles = useThemeStyles()
|
||||||
} = useUser();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
return (
|
return (
|
||||||
<Empty
|
<View className="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
description="您不是管理员"
|
<View className="text-center">
|
||||||
imageSize={80}
|
<Text className="text-gray-400 text-sm">暂无访问权限</Text>
|
||||||
style={{
|
</View>
|
||||||
backgroundColor: 'transparent',
|
</View>
|
||||||
height: 'calc(100vh - 200px)'
|
)
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
</Empty>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View className="bg-gray-100 min-h-screen">
|
||||||
<Text>待开发...</Text>
|
<View className="px-4 py-6" style={themeStyles.primaryBackground}>
|
||||||
</>
|
<Text className="text-white text-lg font-bold">内容管理</Text>
|
||||||
|
<View className="mt-1">
|
||||||
|
<Text className="text-white text-sm opacity-80">管理文章、内容等数据</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
|
||||||
|
<Text className="font-semibold text-gray-800 mb-4 block">管理工具</Text>
|
||||||
|
<ConfigProvider>
|
||||||
|
<Grid columns={4}>
|
||||||
|
<Grid.Item text="文章管理" onClick={() => Taro.navigateTo({ url: '/admin/article/index' })}>
|
||||||
|
<View className="text-center">
|
||||||
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Edit color="#3b82f6" size="20" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Grid.Item>
|
||||||
|
</Grid>
|
||||||
|
</ConfigProvider>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
src/api/app/appProduct/index.ts
Normal file
19
src/api/app/appProduct/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type { AppProduct } from './model';
|
||||||
|
|
||||||
|
const BASE = '/app/product';
|
||||||
|
|
||||||
|
/** 分页查询我的应用 */
|
||||||
|
export async function pageMyApps(params?: { current?: number; size?: number }) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<AppProduct>>>(BASE + '/my/page', { params });
|
||||||
|
if (res.code === 0) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取我参与的应用列表 */
|
||||||
|
export async function listJoinedApps(params?: { page?: number; limit?: number }) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<AppProduct>>>(BASE + '/page', { params });
|
||||||
|
if (res.code === 0) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
51
src/api/app/appProduct/model/index.ts
Normal file
51
src/api/app/appProduct/model/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface AppProduct {
|
||||||
|
productId?: number
|
||||||
|
productName?: string
|
||||||
|
productCode?: string
|
||||||
|
appType?: number
|
||||||
|
icon?: string
|
||||||
|
qrcode?: string
|
||||||
|
description?: string
|
||||||
|
domain?: string
|
||||||
|
homeUrl?: string
|
||||||
|
adminUrl?: string
|
||||||
|
version?: string
|
||||||
|
status?: number
|
||||||
|
statusText?: string
|
||||||
|
running?: number
|
||||||
|
expirationTime?: string
|
||||||
|
createTime?: string
|
||||||
|
updateTime?: string
|
||||||
|
publishStatus?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_TYPE_NAME: Record<number, string> = {
|
||||||
|
10: '网站',
|
||||||
|
20: '微信小程序',
|
||||||
|
30: '抖音小程序',
|
||||||
|
40: '百度小程序',
|
||||||
|
50: '支付宝小程序',
|
||||||
|
60: 'Android APP',
|
||||||
|
70: 'iOS APP',
|
||||||
|
80: 'macOS 应用',
|
||||||
|
90: 'Windows 应用',
|
||||||
|
100: '插件',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_NAME: Record<number, string> = {
|
||||||
|
0: '未开通',
|
||||||
|
1: '运行中',
|
||||||
|
2: '维护中',
|
||||||
|
3: '已关闭',
|
||||||
|
4: '已欠费',
|
||||||
|
5: '违规停机',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_COLOR: Record<number, string> = {
|
||||||
|
0: '#6b7280',
|
||||||
|
1: '#10b981',
|
||||||
|
2: '#f59e0b',
|
||||||
|
3: '#ef4444',
|
||||||
|
4: '#ef4444',
|
||||||
|
5: '#ef4444',
|
||||||
|
}
|
||||||
47
src/api/app/notification/index.ts
Normal file
47
src/api/app/notification/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type { Notification, NotificationParam, UnreadCountResult } from './model';
|
||||||
|
|
||||||
|
const BASE = '/app/notification';
|
||||||
|
|
||||||
|
/** 分页查询通知列表 */
|
||||||
|
export async function pageNotification(params: NotificationParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Notification>>>(BASE + '/page', { params });
|
||||||
|
if (res.code === 0) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询最近通知 */
|
||||||
|
export async function listRecentNotification(params?: { type?: string; limit?: number }) {
|
||||||
|
const res = await request.get<ApiResult<Notification[]>>(BASE + '/recent', { params });
|
||||||
|
if (res.code === 0 && res.data) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取未读数量统计 */
|
||||||
|
export async function getUnreadCount() {
|
||||||
|
const res = await request.get<ApiResult<UnreadCountResult>>(BASE + '/unread-count');
|
||||||
|
if (res.code === 0 && res.data) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记单条通知为已读 */
|
||||||
|
export async function markNotificationRead(id: number) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(BASE + `/read/${id}`);
|
||||||
|
if (res.code === 0) return res.message;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记全部已读 */
|
||||||
|
export async function markAllNotificationRead(data?: { type?: string }) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(BASE + '/read-all', data);
|
||||||
|
if (res.code === 0) return res.message;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单条通知 */
|
||||||
|
export async function removeNotification(id: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(BASE + '/' + id);
|
||||||
|
if (res.code === 0) return res.message;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
41
src/api/app/notification/model/index.ts
Normal file
41
src/api/app/notification/model/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { PageParam } from '@/api/index';
|
||||||
|
|
||||||
|
/** 通知类型 */
|
||||||
|
export type NotificationType = 'ticket' | 'review' | 'system' | 'resource' | 'permission' | 'member' | 'payment';
|
||||||
|
|
||||||
|
/** 通知实体 */
|
||||||
|
export interface Notification {
|
||||||
|
id?: number;
|
||||||
|
userId?: number;
|
||||||
|
type?: NotificationType;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
isRead?: number;
|
||||||
|
refId?: number;
|
||||||
|
refType?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
senderId?: number;
|
||||||
|
senderName?: string;
|
||||||
|
senderAvatar?: string;
|
||||||
|
tenantId?: number;
|
||||||
|
createTime?: string;
|
||||||
|
updateTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知查询参数 */
|
||||||
|
export interface NotificationParam extends PageParam {
|
||||||
|
type?: NotificationType | '';
|
||||||
|
isRead?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未读统计 */
|
||||||
|
export interface UnreadCountResult {
|
||||||
|
total?: number;
|
||||||
|
ticket?: number;
|
||||||
|
review?: number;
|
||||||
|
system?: number;
|
||||||
|
resource?: number;
|
||||||
|
permission?: number;
|
||||||
|
member?: number;
|
||||||
|
payment?: number;
|
||||||
|
}
|
||||||
54
src/api/app/ticket/index.ts
Normal file
54
src/api/app/ticket/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type { Ticket, TicketReply, TicketSubmitForm, TicketQueryParams, TicketStats } from './model';
|
||||||
|
|
||||||
|
const BASE = '/app/ticket';
|
||||||
|
|
||||||
|
/** 查询我的工单(分页) */
|
||||||
|
export async function myTickets(params: TicketQueryParams) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Ticket>>>(BASE + '/my', { params });
|
||||||
|
if (res.code === 0) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交工单 */
|
||||||
|
export async function submitTicket(data: TicketSubmitForm) {
|
||||||
|
const res = await request.post<ApiResult<Ticket>>(BASE + '/submit', data);
|
||||||
|
if (res.code === 0) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工单详情 */
|
||||||
|
export async function getTicket(ticketId: number) {
|
||||||
|
const res = await request.get<ApiResult<Ticket>>(BASE + '/' + ticketId);
|
||||||
|
if (res.code === 0 && res.data) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭工单 */
|
||||||
|
export async function closeTicket(ticketId: number) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(BASE + `/${ticketId}/close`);
|
||||||
|
if (res.code === 0) return res.message;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工单回复列表 */
|
||||||
|
export async function getTicketReplies(ticketId: number) {
|
||||||
|
const res = await request.get<ApiResult<TicketReply[]>>(BASE + `/${ticketId}/replies`);
|
||||||
|
if (res.code === 0 && res.data) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交工单回复 */
|
||||||
|
export async function replyTicket(data: { ticketId: number; content: string }) {
|
||||||
|
const res = await request.post<ApiResult<TicketReply>>(BASE + '/reply', data);
|
||||||
|
if (res.code === 0) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工单统计 */
|
||||||
|
export async function getTicketStats() {
|
||||||
|
const res = await request.get<ApiResult<TicketStats>>(BASE + '/stats');
|
||||||
|
if (res.code === 0 && res.data) return res.data;
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
64
src/api/app/ticket/model/index.ts
Normal file
64
src/api/app/ticket/model/index.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export type TicketPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
export type TicketStatus = 'pending' | 'assigned' | 'processing' | 'resolved' | 'closed' | 'rejected'
|
||||||
|
export type TicketCategory = 'bug' | 'feature' | 'consultation' | 'complaint' | 'other'
|
||||||
|
|
||||||
|
export interface Ticket {
|
||||||
|
ticketId?: number
|
||||||
|
ticketNo?: string
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
productId?: number
|
||||||
|
productName?: string
|
||||||
|
category?: TicketCategory
|
||||||
|
priority?: TicketPriority
|
||||||
|
status?: TicketStatus
|
||||||
|
attachments?: string[]
|
||||||
|
submitUserId?: number
|
||||||
|
submitUserName?: string
|
||||||
|
submitUserAvatar?: string
|
||||||
|
assigneeId?: number
|
||||||
|
assigneeName?: string
|
||||||
|
assigneeAvatar?: string
|
||||||
|
createTime?: string
|
||||||
|
updateTime?: string
|
||||||
|
resolvedTime?: string
|
||||||
|
closedTime?: string
|
||||||
|
replyCount?: number
|
||||||
|
hasUnread?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketReply {
|
||||||
|
replyId?: number
|
||||||
|
ticketId?: number
|
||||||
|
content?: string
|
||||||
|
attachments?: string[]
|
||||||
|
userId?: number
|
||||||
|
userName?: string
|
||||||
|
userAvatar?: string
|
||||||
|
isStaff?: boolean
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketSubmitForm {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
productId?: number
|
||||||
|
category: TicketCategory
|
||||||
|
priority: TicketPriority
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketQueryParams {
|
||||||
|
status?: TicketStatus
|
||||||
|
category?: TicketCategory
|
||||||
|
keywords?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketStats {
|
||||||
|
total?: number
|
||||||
|
pending?: number
|
||||||
|
processing?: number
|
||||||
|
resolved?: number
|
||||||
|
closed?: number
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ export default {
|
|||||||
"phone-auth/index",
|
"phone-auth/index",
|
||||||
'qr-login/index',
|
'qr-login/index',
|
||||||
'qr-confirm/index',
|
'qr-confirm/index',
|
||||||
'unified-qr/index'
|
'unified-qr/index',
|
||||||
|
'webview/index'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,7 +66,9 @@ export default {
|
|||||||
"chat/conversation/index",
|
"chat/conversation/index",
|
||||||
"chat/message/index",
|
"chat/message/index",
|
||||||
"chat/message/add",
|
"chat/message/add",
|
||||||
"chat/message/detail"
|
"chat/message/detail",
|
||||||
|
"notification/index",
|
||||||
|
"apps/index"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
138
src/app.scss
138
src/app.scss
@@ -44,16 +44,12 @@ button[open-type="getPhoneNumber"] {
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
line-height: inherit !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
}
|
||||||
button[open-type="chooseAvatar"] {
|
button[open-type="chooseAvatar"] {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
line-height: inherit !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy-btn{
|
.buy-btn{
|
||||||
@@ -101,3 +97,137 @@ image {
|
|||||||
.admin-feature-item:active {
|
.admin-feature-item:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 科技风格页面动画 */
|
||||||
|
|
||||||
|
/* 粒子动画 */
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-15px) scale(1.1);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanline {
|
||||||
|
0% {
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
top: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanline-green {
|
||||||
|
0% {
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
top: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(168, 85, 247, 0.4), 0 0 40px rgba(168, 85, 247, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 30px rgba(168, 85, 247, 0.6), 0 0 60px rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-green {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(34, 197, 94, 0.4), 0 0 40px rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 30px rgba(34, 197, 94, 0.6), 0 0 60px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 粒子元素样式 - 紫色主题 */
|
||||||
|
.particle {
|
||||||
|
animation: float 3s ease-in-out infinite, pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 粒子元素样式 - 绿色主题 */
|
||||||
|
.particle-green {
|
||||||
|
animation: float 3s ease-in-out infinite, pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 粒子元素样式 - 蓝色主题 */
|
||||||
|
.particle-blue {
|
||||||
|
animation: float 3s ease-in-out infinite, pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扫描线 - 蓝色主题 */
|
||||||
|
@keyframes scanline-blue {
|
||||||
|
0% {
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
top: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发光效果 - 蓝色主题 */
|
||||||
|
@keyframes glow-blue {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4), 0 0 40px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 30px rgba(59, 130, 246, 0.6), 0 0 60px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮发光效果 */
|
||||||
|
.authorize-btn:not([disabled]) {
|
||||||
|
animation: glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
|||||||
title: '统一扫码',
|
title: '统一扫码',
|
||||||
description: '扫码登录和核销一体化功能',
|
description: '扫码登录和核销一体化功能',
|
||||||
icon: <Scan className="text-blue-500" size="24" />,
|
icon: <Scan className="text-blue-500" size="24" />,
|
||||||
color: 'bg-blue-50 border-blue-200',
|
color: 'bg-blue-500 border-blue-200',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navTo('/passport/unified-qr/index', true);
|
navTo('/passport/unified-qr/index', true);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ const DealerIndex: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Grid.Item text="分销订单" onClick={() => navigateToPage('/dealer/orders/index')}>
|
<Grid.Item text="分销订单" onClick={() => navigateToPage('/dealer/orders/index')}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Shopping color="#3b82f6" size="20"/>
|
<Shopping color="#3b82f6" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const InviteStatsPage: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : inviteStats ? (
|
) : inviteStats ? (
|
||||||
<View className="grid grid-cols-2 gap-4">
|
<View className="grid grid-cols-2 gap-4">
|
||||||
<View className="text-center p-4 bg-blue-50 rounded-xl">
|
<View className="text-center p-4 bg-blue-500 rounded-xl">
|
||||||
<ArrowUp size="24" className="text-blue-500 mx-auto mb-2" />
|
<ArrowUp size="24" className="text-blue-500 mx-auto mb-2" />
|
||||||
<Text className="text-2xl font-bold text-blue-600">
|
<Text className="text-2xl font-bold text-blue-600">
|
||||||
{inviteStats.totalInvites || 0}
|
{inviteStats.totalInvites || 0}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ const DealerIndex: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
|
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Shopping color="#3b82f6" size="20"/>
|
<Shopping color="#3b82f6" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import {
|
|||||||
Location,
|
Location,
|
||||||
Tips,
|
Tips,
|
||||||
Ask,
|
Ask,
|
||||||
// Dongdong,
|
|
||||||
People,
|
People,
|
||||||
// AfterSaleService,
|
|
||||||
Logout,
|
Logout,
|
||||||
Shop,
|
Shop,
|
||||||
Jdl,
|
Service,
|
||||||
Service
|
Message,
|
||||||
|
Apps
|
||||||
} from '@nutui/icons-react-taro'
|
} from '@nutui/icons-react-taro'
|
||||||
import {useUser} from "@/hooks/useUser";
|
import {useUser} from "@/hooks/useUser";
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ const UserCell = () => {
|
|||||||
content: '确定要退出登录吗?',
|
content: '确定要退出登录吗?',
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
// 使用 useUser hook 的 logoutUser 方法
|
|
||||||
logoutUser();
|
logoutUser();
|
||||||
Taro.reLaunch({
|
Taro.reLaunch({
|
||||||
url: '/pages/index/index'
|
url: '/pages/index/index'
|
||||||
@@ -54,7 +52,7 @@ const UserCell = () => {
|
|||||||
{hasRole('store') && (
|
{hasRole('store') && (
|
||||||
<Grid.Item text="门店中心" onClick={() => navTo('/store/index', true)}>
|
<Grid.Item text="门店中心" onClick={() => navTo('/store/index', true)}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Shop color="#3b82f6" size="20"/>
|
<Shop color="#3b82f6" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -62,10 +60,10 @@ const UserCell = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRole('developer') && (
|
{hasRole('developer') && (
|
||||||
<Grid.Item text="开发者中心" onClick={() => navTo('/rider/index', true)}>
|
<Grid.Item text="开发者中心" onClick={() => navTo('/passport/webview/index?url=https://websopy.websoft.top/developer', true)}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Jdl color="#3b82f6" size="20"/>
|
<Apps color="#6366f1" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
@@ -81,6 +79,22 @@ const UserCell = () => {
|
|||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Grid.Item text="我的应用" onClick={() => navTo('/user/apps/index', true)}>
|
||||||
|
<View className="text-center">
|
||||||
|
<View className="w-12 h-12 bg-violet-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Apps color="#8b5cf6" size="20"/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Grid.Item>
|
||||||
|
|
||||||
|
<Grid.Item text="消息通知" onClick={() => navTo('/user/notification/index', true)}>
|
||||||
|
<View className="text-center">
|
||||||
|
<View className="w-12 h-12 bg-rose-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Message color="#f43f5e" size="20"/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Grid.Item>
|
||||||
|
|
||||||
<Grid.Item text="收货地址" onClick={() => navTo('/user/address/index', true)}>
|
<Grid.Item text="收货地址" onClick={() => navTo('/user/address/index', true)}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
@@ -89,15 +103,14 @@ const UserCell = () => {
|
|||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
|
||||||
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
|
<Grid.Item text="常见问题" onClick={() => navTo('/user/help/index')}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Ask className={'text-cyan-500'} size="20"/>
|
<Ask className="text-cyan-500" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
|
||||||
{/* 修改联系我们为微信客服 */}
|
|
||||||
<Grid.Item text="联系我们">
|
<Grid.Item text="联系我们">
|
||||||
<Button
|
<Button
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
@@ -105,13 +118,13 @@ const UserCell = () => {
|
|||||||
hover-class="none"
|
hover-class="none"
|
||||||
style={{border: 'none'}}
|
style={{border: 'none'}}
|
||||||
>
|
>
|
||||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Service color="#67C23A" size="20"/>
|
<Service color="#67C23A" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
|
||||||
<Grid.Item text={'实名认证'} onClick={() => navTo('/user/userVerify/index', true)}>
|
<Grid.Item text="实名认证" onClick={() => navTo('/user/userVerify/index', true)}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<ShieldCheck color="#10b981" size="20"/>
|
<ShieldCheck color="#10b981" size="20"/>
|
||||||
@@ -119,7 +132,7 @@ const UserCell = () => {
|
|||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
|
||||||
<Grid.Item text={'推广邀请'} onClick={() => navTo('/dealer/team/index', true)}>
|
<Grid.Item text="推广邀请" onClick={() => navTo('/dealer/team/index', true)}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<People color="#8b5cf6" size="20"/>
|
<People color="#8b5cf6" size="20"/>
|
||||||
@@ -127,35 +140,18 @@ const UserCell = () => {
|
|||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
|
||||||
{/*<Grid.Item text={'我的邀请码'} onClick={() => navTo('/dealer/qrcode/index', true)}>*/}
|
<Grid.Item text="关于我们" onClick={() => navTo('/user/about/index')}>
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <Dongdong color="#f59e0b" size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/*</Grid.Item>*/}
|
|
||||||
|
|
||||||
{/*<Grid.Item text={'管理中心'} onClick={() => navTo('/admin/index', true)}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-red-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <AfterSaleService className={'text-red-500'} size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/*</Grid.Item>*/}
|
|
||||||
|
|
||||||
|
|
||||||
<Grid.Item text={'关于我们'} onClick={() => navTo('/user/about/index')}>
|
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Tips className={'text-amber-500'} size="20"/>
|
<Tips className="text-amber-500" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
|
|
||||||
<Grid.Item text={'安全退出'} onClick={onLogout}>
|
<Grid.Item text="安全退出" onClick={onLogout}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-pink-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-pink-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Logout className={'text-pink-500'} size="20"/>
|
<Logout className="text-pink-500" size="20"/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
@@ -163,53 +159,6 @@ const UserCell = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</View>
|
</View>
|
||||||
{/*<View className="bg-white mx-4 mt-4 rounded-xl">*/}
|
|
||||||
{/* <View className="font-semibold text-gray-800 pt-4 pl-4">账号管理</View>*/}
|
|
||||||
{/* <ConfigProvider>*/}
|
|
||||||
{/* <Grid*/}
|
|
||||||
{/* columns={4}*/}
|
|
||||||
{/* className="no-border-grid"*/}
|
|
||||||
{/* style={{*/}
|
|
||||||
{/* '--nutui-grid-border-color': 'transparent',*/}
|
|
||||||
{/* '--nutui-grid-item-border-width': '0px',*/}
|
|
||||||
{/* border: 'none'*/}
|
|
||||||
{/* } as React.CSSProperties}*/}
|
|
||||||
{/* >*/}
|
|
||||||
{/* <Grid.Item text="账号安全" onClick={() => navTo('/user/profile/profile', true)}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <ShoppingAdd color="#3b82f6" size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* <Grid.Item text="切换主题" onClick={() => navTo('/user/theme/index', true)}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <Location color="#3b82f6" size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* <Grid.Item text={'关于我们'} onClick={() => navTo('/user/about/index')}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <Tips className={'text-amber-500'} size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* <Grid.Item text={'安全退出'} onClick={onLogout}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-pink-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <Logout className={'text-pink-500'} size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* </Grid>*/}
|
|
||||||
{/* </ConfigProvider>*/}
|
|
||||||
{/*</View>*/}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Taro, { useRouter } from '@tarojs/taro'
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
import { Button, Popup } from '@nutui/nutui-react-taro'
|
import { Button, Popup } from '@nutui/nutui-react-taro'
|
||||||
import { checkAndHandleInviteRelation, hasPendingInvite } from "@/utils/invite";
|
import { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite";
|
||||||
import { TenantId } from "@/config/app";
|
import { TenantId } from "@/config/app";
|
||||||
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
|
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
|
||||||
|
|
||||||
@@ -183,6 +183,10 @@ const PhoneAuthLogin = () => {
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
|
// 获取存储的邀请参数
|
||||||
|
const inviteParams = getStoredInviteParams()
|
||||||
|
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0
|
||||||
|
|
||||||
const res = await Taro.request<LoginResponse>({
|
const res = await Taro.request<LoginResponse>({
|
||||||
url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -190,7 +194,10 @@ const PhoneAuthLogin = () => {
|
|||||||
code: phoneCode,
|
code: phoneCode,
|
||||||
encryptedData,
|
encryptedData,
|
||||||
iv,
|
iv,
|
||||||
tenantId: TenantId
|
tenantId: TenantId,
|
||||||
|
notVerifyPhone: true, // 用户未注册时自动注册
|
||||||
|
refereeId: refereeId, // 推荐人ID
|
||||||
|
sceneType: 'save_referee'
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
@@ -266,7 +273,7 @@ const PhoneAuthLogin = () => {
|
|||||||
{/* 微信授权按钮 */}
|
{/* 微信授权按钮 */}
|
||||||
<div className="w-full mb-8">
|
<div className="w-full mb-8">
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white font-medium py-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-green-200 active:scale-95 transition-transform disabled:opacity-50 disabled:active:scale-100"
|
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white font-medium py-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-green-200 transition-transform"
|
||||||
open-type="getPhoneNumber"
|
open-type="getPhoneNumber"
|
||||||
onGetPhoneNumber={handleGetPhoneNumber}
|
onGetPhoneNumber={handleGetPhoneNumber}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -290,9 +297,7 @@ const PhoneAuthLogin = () => {
|
|||||||
{/* 协议勾选 */}
|
{/* 协议勾选 */}
|
||||||
<div className="flex items-start mb-6 px-2">
|
<div className="flex items-start mb-6 px-2">
|
||||||
<div
|
<div
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 mt-1 flex-shrink-0 cursor-pointer transition-colors ${
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 mt-1 flex-shrink-0 cursor-pointer transition-colors`}
|
||||||
agreed ? 'bg-blue-500 border-blue-500' : 'border-gray-300 bg-white hover:border-blue-400'
|
|
||||||
}`}
|
|
||||||
onClick={() => setAgreed(!agreed)}
|
onClick={() => setAgreed(!agreed)}
|
||||||
>
|
>
|
||||||
{agreed && (
|
{agreed && (
|
||||||
@@ -304,7 +309,7 @@ const PhoneAuthLogin = () => {
|
|||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
我已阅读并同意
|
我已阅读并同意
|
||||||
<span
|
<span
|
||||||
className="text-blue-500 cursor-pointer hover:text-blue-600"
|
className="text-blue-500 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowAgreement(true);
|
setShowAgreement(true);
|
||||||
@@ -314,7 +319,7 @@ const PhoneAuthLogin = () => {
|
|||||||
</span>
|
</span>
|
||||||
和
|
和
|
||||||
<span
|
<span
|
||||||
className="text-blue-500 cursor-pointer hover:text-blue-600"
|
className="text-blue-500 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowPrivacy(true);
|
setShowPrivacy(true);
|
||||||
|
|||||||
@@ -1,257 +1,181 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text, Button } from '@tarojs/components';
|
||||||
import { Button, Loading, Card } from '@nutui/nutui-react-taro';
|
|
||||||
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { confirmQRLogin } from '@/api/passport/qr-login';
|
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
|
||||||
import { loginByOpenId } from '@/api/passport/wx-login';
|
import { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫码登录确认页面
|
* 扫码登录确认页面 - 科技风格授权页
|
||||||
*
|
*
|
||||||
* 支持两种场景:
|
* 用户扫描 PC 端二维码后,打开此小程序页面进行微信手机号授权登录
|
||||||
* 1. 主动扫码:用户点击按钮调用微信扫码,扫描 PC 端二维码
|
|
||||||
* 2. URL 扫码(小程序码):用户扫描小程序码后,微信自动打开此页面
|
|
||||||
*
|
|
||||||
* URL 扫码场景:
|
|
||||||
* - 微信「扫普通链接二维码打开小程序」配置的二维码规则:`https://websopy.websoft.top/wx-scan/`
|
|
||||||
* - 扫码后 URL:`https://websopy.websoft.top/wx-scan?token=xxx`
|
|
||||||
* - 小程序接收到参数后自动确认登录
|
|
||||||
*
|
|
||||||
* 登录流程(2026-04-07 实现):
|
|
||||||
* 1. 用户扫码 → 进入 qr-confirm 页面
|
|
||||||
* 2. 页面立即调用 wx.login() 获取 code
|
|
||||||
* 3. 用 code 调用 /api/wx-login/loginByOpenId 获取/验证用户身份
|
|
||||||
* 4. 如果用户不存在 → 跳转到用户页引导注册
|
|
||||||
* 5. 如果用户存在 → 自动调用 confirmQRLogin 确认登录
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 微信获取手机号回调参数类型
|
||||||
|
interface GetPhoneNumberDetail {
|
||||||
|
code?: string;
|
||||||
|
encryptedData?: string;
|
||||||
|
iv?: string;
|
||||||
|
errMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetPhoneNumberEvent {
|
||||||
|
detail: GetPhoneNumberDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录接口返回数据类型
|
||||||
|
interface LoginResponse {
|
||||||
|
data: {
|
||||||
|
access_token: string;
|
||||||
|
user: any;
|
||||||
|
};
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 协议类型
|
||||||
|
type AgreementType = 'service' | 'privacy';
|
||||||
|
|
||||||
const QRConfirmPage: React.FC = () => {
|
const QRConfirmPage: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// 移除 useUser 依赖,改用 wx.login() + loginByOpenId 方式验证用户身份
|
const [authLoading, setAuthLoading] = useState(false); // 授权中状态
|
||||||
const [loading, setLoading] = useState(false);
|
const [agreementChecked, setAgreementChecked] = useState(false); // 协议勾选状态
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [token, setToken] = useState<string>(''); // 登录 token
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [token, setToken] = useState<string>('');
|
|
||||||
const [loginMethod, setLoginMethod] = useState<'scan' | 'url'>('url');
|
|
||||||
const [userInfo, setUserInfo] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 从 URL 参数中获取 token
|
// 从 URL 参数中获取 token
|
||||||
const params = router.params;
|
const params = router.params;
|
||||||
|
|
||||||
// 兼容多种参数名
|
|
||||||
// 1. 小程序码场景:?scene=xxx(微信会将 scene 参数透传到小程序)
|
|
||||||
// 2. 直接参数:?token=xxx
|
|
||||||
// 3. URL 编码参数:?q=xxx(扫普通链接二维码场景)
|
|
||||||
// 4. 旧版参数:?qrCodeKey=xxx
|
|
||||||
let loginToken = params.scene || params.token || params.qrCodeKey || '';
|
let loginToken = params.scene || params.token || params.qrCodeKey || '';
|
||||||
|
|
||||||
// 如果是 q 参数(URL 编码的完整 URL),需要解析
|
// 兼容 q 参数(URL 编码的完整 URL)
|
||||||
if (params.q && !loginToken) {
|
if (params.q && !loginToken) {
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(params.q);
|
const decodedUrl = decodeURIComponent(params.q);
|
||||||
console.log('[QRConfirm] 解码后的 URL:', decodedUrl);
|
|
||||||
|
|
||||||
// 解析 token
|
|
||||||
const url = new URL(decodedUrl);
|
const url = new URL(decodedUrl);
|
||||||
loginToken = url.searchParams.get('token') ||
|
loginToken = url.searchParams.get('token') || url.searchParams.get('qrCodeKey') || '';
|
||||||
url.searchParams.get('qrCodeKey') ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
setLoginMethod('url');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[QRConfirm] 解析 q 参数失败:', e);
|
|
||||||
// 尝试直接使用 q 作为 token
|
|
||||||
loginToken = decodeURIComponent(params.q);
|
loginToken = decodeURIComponent(params.q);
|
||||||
setLoginMethod('url');
|
|
||||||
}
|
}
|
||||||
} else if (loginToken) {
|
|
||||||
setLoginMethod('url');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loginToken) {
|
setToken(loginToken);
|
||||||
setToken(loginToken);
|
|
||||||
console.log('[QRConfirm] 获取到 token:', loginToken);
|
|
||||||
|
|
||||||
// 扫码场景:自动确认登录
|
|
||||||
// scene 参数说明是扫描小程序码进来的,token 参数说明是扫码跳转过来的
|
|
||||||
if (params.scene || params.token || params.qrCodeKey || params.q) {
|
|
||||||
console.log('[QRConfirm] 检测到扫码参数,自动确认登录');
|
|
||||||
setTimeout(() => {
|
|
||||||
handleAutoConfirm(loginToken);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError('无效的登录链接');
|
|
||||||
}
|
|
||||||
}, [router.params]);
|
}, [router.params]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动确认登录(URL 扫码场景)
|
* 处理微信手机号授权
|
||||||
*
|
|
||||||
* 新的登录流程(2026-04-07):
|
|
||||||
* 1. 调用 wx.login() 获取 code
|
|
||||||
* 2. 用 code 调用后端 loginByOpenId 接口验证用户身份
|
|
||||||
* 3. 用户存在 → 调用 confirmQRLogin 确认登录
|
|
||||||
* 4. 用户不存在 → 跳转到用户页引导注册
|
|
||||||
*/
|
*/
|
||||||
const handleAutoConfirm = async (loginToken: string) => {
|
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||||
try {
|
const { code, encryptedData, iv, errMsg } = detail;
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// 1. 调用微信登录获取 code
|
// 检查协议是否勾选
|
||||||
console.log('[QRConfirm] 调用 wx.login() 获取 code...');
|
if (!agreementChecked) {
|
||||||
const loginResult = await Taro.login();
|
Taro.showToast({ title: '请先同意服务协议和隐私政策', icon: 'none' });
|
||||||
|
|
||||||
if (!loginResult.code) {
|
|
||||||
throw new Error('获取微信登录凭证失败');
|
|
||||||
}
|
|
||||||
console.log('[QRConfirm] 获取到 code:', loginResult.code);
|
|
||||||
|
|
||||||
// 2. 用 code 调用后端接口验证用户身份
|
|
||||||
console.log('[QRConfirm] 调用后端 loginByOpenId...');
|
|
||||||
const wxLoginResult = await loginByOpenId({
|
|
||||||
code: loginResult.code,
|
|
||||||
tenantId: 10398 // 使用固定租户ID
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[QRConfirm] loginByOpenId 结果:', wxLoginResult);
|
|
||||||
|
|
||||||
// 3. 判断用户是否存在
|
|
||||||
if (wxLoginResult.success && wxLoginResult.data) {
|
|
||||||
// 用户已注册,保存用户信息并继续确认登录
|
|
||||||
console.log('[QRConfirm] 用户已注册,开始确认登录...');
|
|
||||||
setUserInfo(wxLoginResult.data.user);
|
|
||||||
|
|
||||||
// 调用确认登录
|
|
||||||
await handleConfirmLogin(loginToken, wxLoginResult.data.user);
|
|
||||||
} else {
|
|
||||||
// 用户未注册,跳转到手机号授权登录页面
|
|
||||||
console.log('[QRConfirm] 用户未注册,跳转到手机号授权登录页面');
|
|
||||||
|
|
||||||
Taro.showToast({
|
|
||||||
title: '请先授权登录小程序',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// 跳转到手机号授权登录页面,登录/注册成功后返回扫码确认页面
|
|
||||||
Taro.navigateTo({
|
|
||||||
url: '/passport/phone-auth/index?redirect=/passport/qr-confirm'
|
|
||||||
});
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[QRConfirm] 自动确认登录失败:', err);
|
|
||||||
setError(err.message || '自动确认登录失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确认登录
|
|
||||||
* @param loginToken - 可选的登录token,默认使用页面token
|
|
||||||
* @param wxUserInfo - 微信登录获取的用户信息(可选)
|
|
||||||
*/
|
|
||||||
const handleConfirmLogin = async (loginToken?: string, wxUserInfo?: any) => {
|
|
||||||
const confirmToken = loginToken || token;
|
|
||||||
|
|
||||||
if (!confirmToken) {
|
|
||||||
setError('缺少登录token');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用传入的用户信息,否则尝试从本地存储获取
|
// 用户拒绝授权
|
||||||
const currentUser = wxUserInfo || userInfo;
|
if (errMsg && errMsg.includes('fail')) {
|
||||||
|
Taro.showToast({ title: '需要授权手机号才能完成登录', icon: 'none' });
|
||||||
if (!currentUser?.userId) {
|
return;
|
||||||
// 没有用户信息,尝试从本地存储获取
|
|
||||||
const userId = Taro.getStorageSync('UserId');
|
|
||||||
if (!userId) {
|
|
||||||
setError('请先登录小程序');
|
|
||||||
Taro.showToast({
|
|
||||||
title: '请先登录小程序',
|
|
||||||
icon: 'none'
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
Taro.switchTab({
|
|
||||||
url: '/pages/user/user'
|
|
||||||
});
|
|
||||||
}, 1500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentUser && (currentUser.userId = Number(userId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!code) {
|
||||||
setLoading(true);
|
Taro.showToast({ title: '获取授权信息失败,请重试', icon: 'none' });
|
||||||
setError('');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await confirmQRLogin({
|
await handleAuthLogin(code, encryptedData, iv);
|
||||||
token: confirmToken,
|
};
|
||||||
userId: currentUser.userId,
|
|
||||||
platform: 'wechat',
|
/**
|
||||||
wechatInfo: {
|
* 授权登录
|
||||||
nickname: currentUser.nickname || currentUser.username,
|
*/
|
||||||
avatar: currentUser.avatar
|
const handleAuthLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => {
|
||||||
}
|
try {
|
||||||
|
setAuthLoading(true);
|
||||||
|
|
||||||
|
// 获取邀请参数
|
||||||
|
const inviteParams = getStoredInviteParams();
|
||||||
|
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0;
|
||||||
|
|
||||||
|
const res = await Taro.request<LoginResponse>({
|
||||||
|
url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
code: phoneCode,
|
||||||
|
encryptedData,
|
||||||
|
iv,
|
||||||
|
tenantId: 5,
|
||||||
|
notVerifyPhone: true,
|
||||||
|
refereeId,
|
||||||
|
sceneType: 'save_referee'
|
||||||
|
},
|
||||||
|
header: { 'content-type': 'application/json', 'TenantId': 5 }
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据 status 判断成功:confirmed 表示登录成功
|
if (res.data.code !== 0) {
|
||||||
const isConfirmed = result.status === 'confirmed' || result.success === true;
|
throw new Error(res.data.message || '登录失败');
|
||||||
|
|
||||||
if (isConfirmed) {
|
|
||||||
setConfirmed(true);
|
|
||||||
Taro.showToast({
|
|
||||||
title: result.successMessage || result.message || '登录确认成功',
|
|
||||||
icon: 'success',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3秒后自动关闭或返回
|
|
||||||
setTimeout(() => {
|
|
||||||
// 尝试返回上一页,如果没有则关闭
|
|
||||||
const pages = Taro.getCurrentPages();
|
|
||||||
if (pages.length > 1) {
|
|
||||||
Taro.navigateBack();
|
|
||||||
} else {
|
|
||||||
// 小程序场景下,提示用户回到 PC 端
|
|
||||||
Taro.showModal({
|
|
||||||
title: '登录成功',
|
|
||||||
content: '请回到电脑端刷新页面',
|
|
||||||
showCancel: false,
|
|
||||||
confirmText: '我知道了'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
} else if (result.status === 'bind_phone' || result.needBindPhone) {
|
|
||||||
// 需要绑定手机号
|
|
||||||
Taro.showToast({
|
|
||||||
title: '请先绑定手机号',
|
|
||||||
icon: 'none'
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
Taro.redirectTo({ url: '/passport/sms-login' });
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
|
||||||
setError(result.message || '登录确认失败');
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[QRConfirm] 确认登录失败:', err);
|
if (res.data.data?.user) {
|
||||||
setError(err.message || '登录确认失败');
|
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user);
|
||||||
|
|
||||||
|
// 处理邀请关系
|
||||||
|
if (hasPendingInvite()) {
|
||||||
|
try {
|
||||||
|
await checkAndHandleInviteRelation();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('处理邀请关系失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Taro.showToast({ title: '授权成功,正在确认登录...', icon: 'none' });
|
||||||
|
|
||||||
|
// 延迟确认扫码登录
|
||||||
|
setTimeout(() => handleConfirmQRLogin(res.data.data.user), 1500);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Taro.showToast({ title: error.message || '授权失败', icon: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setAuthLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动确认登录(主动扫码场景)
|
* 确认扫码登录
|
||||||
*/
|
*/
|
||||||
const handleManualConfirm = () => {
|
const handleConfirmQRLogin = async (userInfo: any) => {
|
||||||
handleConfirmLogin();
|
if (!token) {
|
||||||
|
Taro.showToast({ title: '缺少登录token', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await Taro.request({
|
||||||
|
url: `${SERVER_API_URL}/qr-login/confirm`,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
userId: userInfo.userId,
|
||||||
|
platform: 'wechat',
|
||||||
|
wechatInfo: {
|
||||||
|
nickname: userInfo.nickname || userInfo.username,
|
||||||
|
avatar: userInfo.avatar
|
||||||
|
}
|
||||||
|
},
|
||||||
|
header: { 'content-type': 'application/json', 'TenantId': 5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.success || res.data.status === 'confirmed') {
|
||||||
|
Taro.showToast({ title: '登录确认成功', icon: 'success', duration: 1500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.switchTab({ url: '/pages/user/user' });
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: res.data.message || '登录确认失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Taro.showToast({ title: err.message || '确认登录失败', icon: 'error' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,298 +186,159 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
if (pages.length > 1) {
|
if (pages.length > 1) {
|
||||||
Taro.navigateBack();
|
Taro.navigateBack();
|
||||||
} else {
|
} else {
|
||||||
Taro.switchTab({
|
Taro.switchTab({ url: '/pages/user/user' });
|
||||||
url: '/pages/user/user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// 打开协议页面
|
||||||
* 重试
|
const openAgreement = (type: AgreementType) => {
|
||||||
*/
|
const urlMap = {
|
||||||
const handleRetry = () => {
|
service: 'https://websopy.websoft.top/agreement',
|
||||||
setError('');
|
privacy: 'https://websopy.websoft.top/privacy',
|
||||||
setConfirmed(false);
|
};
|
||||||
handleConfirmLogin();
|
const targetUrl = encodeURIComponent(urlMap[type]);
|
||||||
};
|
Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` });
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开微信扫码
|
|
||||||
*/
|
|
||||||
const handleScan = () => {
|
|
||||||
Taro.scanCode({
|
|
||||||
success: async (res) => {
|
|
||||||
console.log('[QRConfirm] 扫码成功:', res);
|
|
||||||
|
|
||||||
// 解析二维码内容
|
|
||||||
let scanToken = '';
|
|
||||||
const qrContent = res.result;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 尝试解析 URL
|
|
||||||
if (qrContent.includes('http')) {
|
|
||||||
const url = new URL(qrContent);
|
|
||||||
scanToken = url.searchParams.get('token') ||
|
|
||||||
url.searchParams.get('qrCodeKey') ||
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试解析 JSON
|
|
||||||
if (!scanToken && qrContent.startsWith('{')) {
|
|
||||||
const parsed = JSON.parse(qrContent);
|
|
||||||
scanToken = parsed.token || parsed.qrCodeKey || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接作为 token
|
|
||||||
if (!scanToken && qrContent.length >= 32) {
|
|
||||||
scanToken = qrContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scanToken) {
|
|
||||||
setToken(scanToken);
|
|
||||||
setLoginMethod('scan');
|
|
||||||
handleConfirmLogin(scanToken);
|
|
||||||
} else {
|
|
||||||
setError('无效的二维码内容');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[QRConfirm] 解析二维码失败:', e);
|
|
||||||
setError('二维码解析失败');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
|
||||||
console.error('[QRConfirm] 扫码失败:', err);
|
|
||||||
if (err.errMsg !== 'scanCode:fail cancel') {
|
|
||||||
setError('扫码失败,请重试');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染状态:加载中
|
|
||||||
const renderLoading = () => (
|
|
||||||
<View className="mb-6">
|
|
||||||
<View className="w-16 h-16 mx-auto flex items-center justify-center">
|
|
||||||
<Loading className="text-blue-500" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染状态:成功
|
|
||||||
const renderSuccess = () => (
|
|
||||||
<View className="mb-6">
|
|
||||||
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
|
||||||
<Success className="text-green-500" size="32" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染状态:错误
|
|
||||||
const renderError = () => (
|
|
||||||
<View className="mb-6">
|
|
||||||
<View className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
|
|
||||||
<Failure className="text-red-500" size="32" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染状态:初始(用户未扫码)
|
|
||||||
const renderInitial = () => (
|
|
||||||
<View className="mb-6">
|
|
||||||
<View className="w-16 h-16 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
|
||||||
<User size="32" className="text-blue-500" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 获取标题
|
|
||||||
const getTitle = () => {
|
|
||||||
if (loading) return '正在确认登录...';
|
|
||||||
if (confirmed) return '登录确认成功';
|
|
||||||
if (error) return '登录确认失败';
|
|
||||||
return loginMethod === 'url' ? '扫码登录确认' : '确认登录';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取描述
|
|
||||||
const getDescription = () => {
|
|
||||||
if (loading) return '请稍候,正在为您确认登录';
|
|
||||||
if (confirmed) return '您已成功确认登录,网页端将自动登录';
|
|
||||||
if (error) return error;
|
|
||||||
if (loginMethod === 'url') {
|
|
||||||
return '检测到登录请求,是否确认登录?';
|
|
||||||
}
|
|
||||||
const displayName = userInfo?.nickname || userInfo?.username || '当前用户';
|
|
||||||
return `确认使用 ${displayName} 登录网页端?`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 科技风格授权页面 - 蓝色主题
|
||||||
return (
|
return (
|
||||||
<View className="qr-confirm-page min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
<View className="min-h-screen relative overflow-hidden" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
|
||||||
<View className="p-4">
|
|
||||||
{/* Logo/品牌区域 */}
|
{/* 背景科技元素 */}
|
||||||
<View className="text-center pt-8 pb-6">
|
{/* 网格背景 */}
|
||||||
<View className="w-20 h-20 mx-auto bg-white rounded-2xl shadow-lg flex items-center justify-center mb-4">
|
<View style={{
|
||||||
<Text className="text-3xl">🔐</Text>
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '50px 50px', opacity: 0.5,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 渐变光晕 - 左上 */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: '-30%', left: '-20%', width: '60%', height: '60%',
|
||||||
|
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.25) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(40px)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 渐变光晕 - 右下 */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', bottom: '-20%', right: '-20%', width: '50%', height: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(50px)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 动态粒子光点 */}
|
||||||
|
{[
|
||||||
|
{ top: '15%', left: '20%', size: 4, delay: '0s' },
|
||||||
|
{ top: '25%', left: '80%', size: 3, delay: '0.5s' },
|
||||||
|
{ top: '40%', left: '15%', size: 5, delay: '1s' },
|
||||||
|
{ top: '35%', left: '85%', size: 3, delay: '1.5s' },
|
||||||
|
{ top: '55%', left: '10%', size: 4, delay: '2s' },
|
||||||
|
{ top: '60%', left: '90%', size: 3, delay: '0.3s' },
|
||||||
|
{ top: '75%', left: '25%', size: 4, delay: '1.2s' },
|
||||||
|
{ top: '80%', left: '75%', size: 5, delay: '0.8s' },
|
||||||
|
].map((particle, index) => (
|
||||||
|
<View key={index} className="particle-blue" style={{
|
||||||
|
position: 'absolute', top: particle.top, left: particle.left,
|
||||||
|
width: particle.size, height: particle.size, borderRadius: '50%',
|
||||||
|
background: index % 2 === 0 ? '#3b82f6' : '#6366f1',
|
||||||
|
boxShadow: index % 2 === 0
|
||||||
|
? '0 0 10px rgba(59, 130, 246, 0.8), 0 0 20px rgba(59, 130, 246, 0.4)'
|
||||||
|
: '0 0 10px rgba(99, 102, 241, 0.8), 0 0 20px rgba(99, 102, 241, 0.4)',
|
||||||
|
animationDelay: particle.delay,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 扫描线效果 */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: '2px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.6), transparent)',
|
||||||
|
animation: 'scanline-blue 3s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<View className="relative z-10 flex flex-col items-center justify-center min-h-screen px-8">
|
||||||
|
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<View className="flex flex-col items-center mb-12">
|
||||||
|
<View style={{
|
||||||
|
width: '100px', height: '100px', borderRadius: '24px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(99, 102, 241, 0.2))',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: '20px', position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Logo 内光效 */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: '-50%', left: '-50%', width: '200%', height: '200%',
|
||||||
|
background: 'conic-gradient(from 0deg, transparent, rgba(59, 130, 246, 0.15), transparent, rgba(99, 102, 241, 0.15), transparent)',
|
||||||
|
animation: 'rotate 4s linear infinite',
|
||||||
|
}} />
|
||||||
|
<Text style={{ fontSize: '48px', position: 'relative', zIndex: 1 }}>🔐</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-gray-400 text-sm">WebSoft Platform</Text>
|
|
||||||
|
<Text style={{
|
||||||
|
fontSize: '28px', fontWeight: '700',
|
||||||
|
background: 'linear-gradient(90deg, #3b82f6, #6366f1, #8b5cf6)',
|
||||||
|
WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text', marginBottom: '8px',
|
||||||
|
}}>websopy</Text>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
width: '60px', height: '3px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, #3b82f6, transparent)',
|
||||||
|
marginTop: '20px', borderRadius: '2px',
|
||||||
|
}} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 主要内容卡片 */}
|
{/* 主按钮 - 渐变发光按钮 */}
|
||||||
<Card className="bg-white rounded-2xl shadow-xl -mt-4">
|
<View style={{
|
||||||
<View className="p-6 text-center">
|
width: '100%', maxWidth: '320px',
|
||||||
{/* 状态图标 */}
|
background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 50%, #6366f1 100%)',
|
||||||
{loading ? renderLoading() : confirmed ? renderSuccess() : error ? renderError() : renderInitial()}
|
borderRadius: '30px', padding: '2px',
|
||||||
|
boxShadow: '0 0 30px rgba(59, 130, 246, 0.4), 0 0 60px rgba(59, 130, 246, 0.2)',
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
open-type="getPhoneNumber"
|
||||||
|
onGetPhoneNumber={handleGetPhoneNumber}
|
||||||
|
disabled={authLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: '52px', fontSize: '17px', fontWeight: '600',
|
||||||
|
color: '#fff', background: 'transparent', borderRadius: '28px',
|
||||||
|
border: 'none', padding: '0', boxSizing: 'border-box', lineHeight: '52px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authLoading ? '授权中...' : '微信手机号登录'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 标题 */}
|
{/* 取消按钮 */}
|
||||||
<Text className="text-xl font-bold text-gray-800 mb-2 block">
|
<View style={{ width: '100%', maxWidth: '320px', height: '44px', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: '16px' }} onClick={handleCancel}>
|
||||||
{getTitle()}
|
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '14px' }}>取消</Text>
|
||||||
</Text>
|
</View>
|
||||||
|
|
||||||
{/* 描述 */}
|
{/* 协议勾选 */}
|
||||||
<Text className="text-gray-600 mb-6 block text-sm">
|
<View className="flex items-center justify-center mt-6">
|
||||||
{getDescription()}
|
<View style={{ padding: '8px', marginRight: '4px' }} onClick={() => setAgreementChecked(!agreementChecked)}>
|
||||||
</Text>
|
<View style={{
|
||||||
|
width: '18px', height: '18px', borderRadius: '4px',
|
||||||
{/* 用户信息 */}
|
border: agreementChecked ? 'none' : '1px solid rgba(255, 255, 255, 0.3)',
|
||||||
{!loading && !confirmed && !error && userInfo && (
|
background: agreementChecked ? 'linear-gradient(135deg, #1d4ed8, #3b82f6)' : 'transparent',
|
||||||
<View className="bg-gray-50 rounded-xl p-4 mb-6">
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
<View className="flex items-center justify-center">
|
}}>
|
||||||
{userInfo.avatar ? (
|
{agreementChecked && <Text style={{ color: '#fff', fontSize: '12px' }}>✓</Text>}
|
||||||
<View
|
|
||||||
className="w-12 h-12 rounded-full bg-blue-100 mr-3 overflow-hidden"
|
|
||||||
style={{ backgroundImage: `url(${userInfo.avatar})`, backgroundSize: 'cover' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
|
||||||
<User className="text-blue-500" size="20" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View className="text-left">
|
|
||||||
<Text className="text-sm font-medium text-gray-800 block">
|
|
||||||
{userInfo.nickname || userInfo.username || '用户'}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-gray-500 block">
|
|
||||||
ID: {userInfo.userId}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Token 信息 */}
|
|
||||||
{token && !loading && !confirmed && (
|
|
||||||
<View className="bg-blue-50 rounded-lg p-3 mb-4">
|
|
||||||
<Text className="text-xs text-blue-600">
|
|
||||||
登录令牌:{token.substring(0, 20)}...{token.substring(token.length - 10)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<View className="space-y-3">
|
|
||||||
{loading ? (
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="large"
|
|
||||||
disabled
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
>
|
|
||||||
确认中...
|
|
||||||
</Button>
|
|
||||||
) : confirmed ? (
|
|
||||||
<Button
|
|
||||||
type="success"
|
|
||||||
size="large"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</Button>
|
|
||||||
) : error ? (
|
|
||||||
<View className="space-y-2">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="large"
|
|
||||||
onClick={handleScan}
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
>
|
|
||||||
扫码其他二维码
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
) : loginMethod === 'scan' ? (
|
|
||||||
<View>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
onClick={handleManualConfirm}
|
|
||||||
className="w-full mb-2 rounded-xl"
|
|
||||||
disabled={!token}
|
|
||||||
>
|
|
||||||
确认登录
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="large"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
// URL 扫码场景:自动确认中
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
onClick={handleManualConfirm}
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
>
|
|
||||||
确认登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '12px' }}>我已阅读并同意</Text>
|
||||||
|
<Text style={{ color: '#3b82f6', fontSize: '12px', padding: '4px 2px' }} onClick={() => openAgreement('service')}>《服务协议》</Text>
|
||||||
|
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '12px' }}>和</Text>
|
||||||
|
<Text style={{ color: '#3b82f6', fontSize: '12px', padding: '4px 2px' }} onClick={() => openAgreement('privacy')}>《隐私政策》</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 安全提示 */}
|
{/* 底部装饰 */}
|
||||||
<Card className="bg-yellow-50 border border-yellow-200 rounded-xl mt-4">
|
<View style={{ position: 'absolute', bottom: '40px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<View className="p-4">
|
<View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#3b82f6', boxShadow: '0 0 10px rgba(59, 130, 246, 0.8)' }} />
|
||||||
<View className="flex items-start">
|
<Text style={{ color: 'rgba(255, 255, 255, 0.3)', fontSize: '11px' }}>安全加密连接</Text>
|
||||||
<Tips className="text-yellow-600 mr-2 mt-1" size="16" />
|
<View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#6366f1', boxShadow: '0 0 10px rgba(99, 102, 241, 0.8)' }} />
|
||||||
<View>
|
|
||||||
<Text className="text-sm font-medium text-yellow-800 mb-1 block">
|
|
||||||
安全提示
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-yellow-700 block">
|
|
||||||
请确认这是您本人的登录操作。如果不是,请点击取消并检查账户安全。
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 底部说明 */}
|
|
||||||
<View className="text-center mt-6 pb-8">
|
|
||||||
<Text className="text-xs text-gray-400">
|
|
||||||
如有问题,请联系管理员
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ const UnifiedQRPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 功能说明 */}
|
{/* 功能说明 */}
|
||||||
<Card className="m-4 bg-blue-50 border border-blue-200">
|
<Card className="m-4 bg-blue-500 border border-blue-200">
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<View className="flex items-start">
|
<View className="flex items-start">
|
||||||
<Tips className="text-blue-600 mr-2 mt-1" size="16" />
|
<Tips className="text-blue-600 mr-2 mt-1" size="16" />
|
||||||
|
|||||||
5
src/passport/webview/index.config.ts
Normal file
5
src/passport/webview/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '详情',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
};
|
||||||
22
src/passport/webview/index.tsx
Normal file
22
src/passport/webview/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WebView } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 WebView 页面
|
||||||
|
* 通过 url 参数指定要加载的网页地址
|
||||||
|
* 用于显示注册协议、隐私政策等网页内容
|
||||||
|
*/
|
||||||
|
const WebViewPage: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const url = decodeURIComponent(router.params.url || '');
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
Taro.navigateBack();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WebView src={url} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebViewPage;
|
||||||
@@ -1,14 +1,58 @@
|
|||||||
import React from 'react'
|
import React, { useCallback, useState, useEffect } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {View, Text} from '@tarojs/components'
|
import { View, Text } from '@tarojs/components'
|
||||||
import {Avatar, Button} from '@nutui/nutui-react-taro'
|
import { Avatar, Button, ConfigProvider, Grid, Empty } from '@nutui/nutui-react-taro'
|
||||||
import {User} from '@nutui/icons-react-taro'
|
import { User, Location, Order, Refresh } from '@nutui/icons-react-taro'
|
||||||
import {useThemeStyles} from '@/hooks/useTheme'
|
import { useThemeStyles } from '@/hooks/useTheme'
|
||||||
import {useUser} from '@/hooks/useUser'
|
import { useUser } from '@/hooks/useUser'
|
||||||
|
import { listShopOrder } from '@/api/shop/shopOrder'
|
||||||
|
import type { ShopOrder } from '@/api/shop/shopOrder/model'
|
||||||
|
|
||||||
const RiderIndex: React.FC = () => {
|
const RiderIndex: React.FC = () => {
|
||||||
const themeStyles = useThemeStyles()
|
const themeStyles = useThemeStyles()
|
||||||
const {isLoggedIn, loading: userLoading, getAvatarUrl, getDisplayName} = useUser()
|
const { isLoggedIn, loading: userLoading, getAvatarUrl, getDisplayName } = useUser()
|
||||||
|
|
||||||
|
const [stats, setStats] = useState({ pending: 0, delivering: 0, completed: 0, today: 0 })
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
const userId = Taro.getStorageSync('UserId')
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// 查询待配送和配送中的订单
|
||||||
|
const [pendingRes, deliveringRes, completedRes] = await Promise.allSettled([
|
||||||
|
listShopOrder({ riderId: Number(userId), orderStatus: 30, page: 1, limit: 1 }),
|
||||||
|
listShopOrder({ riderId: Number(userId), orderStatus: 40, page: 1, limit: 1 }),
|
||||||
|
listShopOrder({ riderId: Number(userId), orderStatus: 50, page: 1, limit: 1 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const pending = pendingRes.status === 'fulfilled' ? (pendingRes.value?.count || 0) : 0
|
||||||
|
const delivering = deliveringRes.status === 'fulfilled' ? (deliveringRes.value?.count || 0) : 0
|
||||||
|
const completed = completedRes.status === 'fulfilled' ? (completedRes.value?.count || 0) : 0
|
||||||
|
|
||||||
|
setStats({ pending, delivering, completed, today: pending + delivering })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载骑手统计失败', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, loadStats])
|
||||||
|
|
||||||
|
const navigateToPage = (url: string) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
Taro.showToast({ title: '请先登录', icon: 'none', duration: 1500 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.navigateTo({ url })
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLoggedIn && !userLoading) {
|
if (!isLoggedIn && !userLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +60,7 @@ const RiderIndex: React.FC = () => {
|
|||||||
<View className="bg-white rounded-xl p-4">
|
<View className="bg-white rounded-xl p-4">
|
||||||
<Text className="text-gray-700">请先登录后再进入骑手中心</Text>
|
<Text className="text-gray-700">请先登录后再进入骑手中心</Text>
|
||||||
<View className="mt-3">
|
<View className="mt-3">
|
||||||
<Button type="primary" onClick={() => Taro.navigateTo({url: '/passport/login'})}>
|
<Button type="primary" onClick={() => Taro.navigateTo({ url: '/passport/login' })}>
|
||||||
去登录
|
去登录
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -25,39 +69,129 @@ const RiderIndex: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: '待取货', value: stats.pending, color: 'bg-orange-50', textColor: 'text-orange-600' },
|
||||||
|
{ label: '配送中', value: stats.delivering, color: 'bg-blue-50', textColor: 'text-blue-600' },
|
||||||
|
{ label: '已完成', value: stats.completed, color: 'bg-green-50', textColor: 'text-green-600' },
|
||||||
|
{ label: '今日任务', value: stats.today, color: 'bg-purple-50', textColor: 'text-purple-600' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-gray-100 min-h-screen">
|
<View className="bg-gray-100 min-h-screen">
|
||||||
|
{/* 头部信息 */}
|
||||||
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
|
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
|
||||||
|
<View
|
||||||
|
className="absolute w-32 h-32 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.1)', top: '-16px', right: '-16px' }}
|
||||||
|
></View>
|
||||||
|
<View
|
||||||
|
className="absolute w-24 h-24 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.08)', bottom: '-12px', left: '-12px' }}
|
||||||
|
></View>
|
||||||
|
|
||||||
<View className="flex items-center justify-between relative z-10">
|
<View className="flex items-center justify-between relative z-10">
|
||||||
<Avatar
|
<View className="flex items-center">
|
||||||
size="50"
|
<Avatar
|
||||||
src={getAvatarUrl()}
|
size="50"
|
||||||
icon={<User />}
|
src={getAvatarUrl()}
|
||||||
className="mr-4"
|
icon={<User />}
|
||||||
style={{border: '2px solid rgba(255, 255, 255, 0.3)'}}
|
className="mr-4"
|
||||||
/>
|
style={{ border: '2px solid rgba(255, 255, 255, 0.3)' }}
|
||||||
<View className="flex-1 flex-col">
|
/>
|
||||||
<View className="text-white text-lg font-bold mb-1">{getDisplayName()}</View>
|
<View>
|
||||||
<View className="text-sm" style={{color: 'rgba(255, 255, 255, 0.8)'}}>
|
<View className="text-white text-lg font-bold mb-1">{getDisplayName()}</View>
|
||||||
骑手中心
|
<View className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||||
|
骑手中心
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.18)',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.25)'
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
onClick={loadStats}
|
||||||
|
>
|
||||||
|
<Refresh size="14" />
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="p-4">
|
{/* 数据统计卡片 */}
|
||||||
<View className="bg-white rounded-xl p-4">
|
<View className="mx-4 -mt-6 rounded-xl bg-white p-4 relative z-10">
|
||||||
<Text className="text-gray-800 font-semibold">常用功能</Text>
|
<View className="font-semibold text-gray-400 text-sm mb-3">今日概览</View>
|
||||||
<View className="mt-3">
|
<View className="grid grid-cols-4 gap-2">
|
||||||
<Button type="primary" block onClick={() => Taro.navigateTo({url: '/rider/orders/index'})}>
|
{statCards.map((card) => (
|
||||||
我的配送订单
|
<View key={card.label} className={`${card.color} rounded-lg p-3 text-center`}>
|
||||||
</Button>
|
<View className={`text-2xl font-bold ${card.textColor}`}>{card.value}</View>
|
||||||
</View>
|
<View className="text-xs text-gray-500 mt-1">{card.label}</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 快捷功能入口 */}
|
||||||
|
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
|
||||||
|
<View className="font-semibold mb-4 text-gray-800">快捷功能</View>
|
||||||
|
<ConfigProvider>
|
||||||
|
<Grid
|
||||||
|
columns={3}
|
||||||
|
style={{
|
||||||
|
'--nutui-grid-border-color': 'transparent',
|
||||||
|
'--nutui-grid-item-border-width': '0px',
|
||||||
|
border: 'none'
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<Grid.Item text="待取货" onClick={() => navigateToPage('/rider/orders/index')}>
|
||||||
|
<View className="text-center">
|
||||||
|
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Order color="#f97316" size="20" />
|
||||||
|
</View>
|
||||||
|
{stats.pending > 0 && (
|
||||||
|
<View className="absolute top-0 right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{stats.pending > 9 ? '9+' : stats.pending}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Grid.Item>
|
||||||
|
|
||||||
|
<Grid.Item text="配送中" onClick={() => navigateToPage('/rider/orders/index')}>
|
||||||
|
<View className="text-center">
|
||||||
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Location color="#3b82f6" size="20" />
|
||||||
|
</View>
|
||||||
|
{stats.delivering > 0 && (
|
||||||
|
<View className="absolute top-0 right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{stats.delivering > 9 ? '9+' : stats.delivering}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Grid.Item>
|
||||||
|
|
||||||
|
<Grid.Item text="全部订单" onClick={() => navigateToPage('/rider/orders/index')}>
|
||||||
|
<View className="text-center">
|
||||||
|
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Order color="#6b7280" size="20" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Grid.Item>
|
||||||
|
</Grid>
|
||||||
|
</ConfigProvider>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
{stats.today === 0 && !loading && (
|
||||||
|
<View className="mx-4 mt-4">
|
||||||
|
<Empty description="暂无配送任务" imageSize={60} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="h-20"></View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RiderIndex
|
export default RiderIndex
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ const StoreIndex: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Grid.Item text="门店订单" onClick={() => navigateToPage('/store/orders/index')}>
|
<Grid.Item text="门店订单" onClick={() => navigateToPage('/store/orders/index')}>
|
||||||
<View className="text-center">
|
<View className="text-center">
|
||||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||||
<Shopping color="#3b82f6" size="20" />
|
<Shopping color="#3b82f6" size="20" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
3
src/user/apps/index.config.ts
Normal file
3
src/user/apps/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
navigationBarTitleText: '我的应用',
|
||||||
|
})
|
||||||
192
src/user/apps/index.tsx
Normal file
192
src/user/apps/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Empty, InfiniteLoading, PullToRefresh, Tag } from '@nutui/nutui-react-taro'
|
||||||
|
import { useThemeStyles } from '@/hooks/useTheme'
|
||||||
|
import { listJoinedApps } from '@/api/app/appProduct'
|
||||||
|
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||||
|
import { APP_TYPE_NAME, STATUS_NAME, STATUS_COLOR } from '@/api/app/appProduct/model'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const MyAppsPage = () => {
|
||||||
|
const themeStyles = useThemeStyles()
|
||||||
|
const [list, setList] = useState<AppProduct[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [current, setCurrent] = useState(1)
|
||||||
|
|
||||||
|
const loadApps = useCallback(async (isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const page = isRefresh ? 1 : current
|
||||||
|
const res = await listJoinedApps({ page, limit: 20 })
|
||||||
|
const newList = res?.list || []
|
||||||
|
if (isRefresh) {
|
||||||
|
setList(newList)
|
||||||
|
setCurrent(2)
|
||||||
|
} else {
|
||||||
|
setList(prev => [...prev, ...newList])
|
||||||
|
setCurrent(prev => prev + 1)
|
||||||
|
}
|
||||||
|
setHasMore(newList.length >= 20)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载应用失败', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [loading, current])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
loadApps(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEnterAdmin = (app: AppProduct) => {
|
||||||
|
if (!app.adminUrl) {
|
||||||
|
Taro.showToast({ title: '暂无管理入口', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = app.adminUrl.startsWith('http') ? app.adminUrl : `https://${app.adminUrl}`
|
||||||
|
Taro.navigateTo({ url: `/passport/webview/index?url=${encodeURIComponent(url)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnterHome = (app: AppProduct) => {
|
||||||
|
if (!app.homeUrl && !app.domain) {
|
||||||
|
Taro.showToast({ title: '暂无访问地址', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = (app.homeUrl || app.domain || '').startsWith('http')
|
||||||
|
? (app.homeUrl || app.domain)
|
||||||
|
: `https://${app.homeUrl || app.domain}`
|
||||||
|
Taro.navigateTo({ url: `/passport/webview/index?url=${encodeURIComponent(url)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-gray-100 min-h-screen">
|
||||||
|
{/* 头部 */}
|
||||||
|
<View className="px-4 py-6" style={themeStyles.primaryBackground}>
|
||||||
|
<Text className="text-white text-lg font-bold">我的应用</Text>
|
||||||
|
<View className="mt-1">
|
||||||
|
<Text className="text-white text-sm opacity-80">管理您开通的应用与服务</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PullToRefresh onRefresh={() => loadApps(true)} className="mt-4">
|
||||||
|
<View className="mx-4 rounded-xl overflow-hidden">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="bg-white rounded-xl">
|
||||||
|
<Empty description="暂无应用,请前往PC端开通" imageSize={80} className="py-16" />
|
||||||
|
<View className="px-4 pb-6">
|
||||||
|
<View
|
||||||
|
className="text-center text-sm text-blue-500"
|
||||||
|
onClick={() => Taro.navigateTo({
|
||||||
|
url: '/passport/webview/index?url=' + encodeURIComponent('https://websopy.websoft.top/products')
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
去开通应用 >
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
list.map((app) => {
|
||||||
|
const statusName = STATUS_NAME[app.status ?? 0] || '未知'
|
||||||
|
const statusColor = STATUS_COLOR[app.status ?? 0] || '#6b7280'
|
||||||
|
const typeName = APP_TYPE_NAME[app.appType ?? 10] || '应用'
|
||||||
|
const isExpired = app.expirationTime && dayjs(app.expirationTime).isBefore(dayjs())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={app.productId}
|
||||||
|
className="bg-white mx-0 mb-3 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<View className="flex items-start gap-3">
|
||||||
|
{/* 图标 */}
|
||||||
|
<View className="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||||
|
{app.icon ? (
|
||||||
|
<image src={app.icon} className="w-full h-full" mode="aspectFit" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-lg text-gray-400">{typeName[0]}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<View className="flex-1 min-w-0">
|
||||||
|
<View className="flex items-center gap-2 mb-1">
|
||||||
|
<Text className="font-semibold text-gray-900 text-sm truncate flex-1">
|
||||||
|
{app.productName || '未命名应用'}
|
||||||
|
</Text>
|
||||||
|
<Tag
|
||||||
|
plain
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '0 4px',
|
||||||
|
borderColor: statusColor,
|
||||||
|
color: statusColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpired ? '已过期' : statusName}
|
||||||
|
</Tag>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex items-center gap-2 mb-1">
|
||||||
|
<Text className="text-xs text-gray-400">{typeName}</Text>
|
||||||
|
{app.version && (
|
||||||
|
<Text className="text-xs text-gray-300">v{app.version}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{app.description && (
|
||||||
|
<Text className="text-xs text-gray-500 line-clamp-2 mb-2">{app.description}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<View className="flex gap-2 mt-2">
|
||||||
|
{app.adminUrl && (
|
||||||
|
<View
|
||||||
|
className="px-3 py-2 bg-blue-500 rounded-lg text-center"
|
||||||
|
onClick={() => handleEnterAdmin(app)}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-blue-600">管理后台</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{(app.homeUrl || app.domain) && (
|
||||||
|
<View
|
||||||
|
className="px-3 py-2 bg-green-50 rounded-lg text-center"
|
||||||
|
onClick={() => handleEnterHome(app)}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-green-600">访问前台</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{app.expirationTime && (
|
||||||
|
<View className="flex items-center ml-auto">
|
||||||
|
<Text className={`text-xs ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>
|
||||||
|
到期:{dayjs(app.expirationTime).format('YYYY-MM-DD')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<InfiniteLoading
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={() => loadApps(false)}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<View className="py-4 text-center">
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{loading ? '加载中...' : hasMore ? '下拉加载更多' : '没有更多了'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</InfiniteLoading>
|
||||||
|
</PullToRefresh>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyAppsPage
|
||||||
@@ -160,7 +160,7 @@ const GiftCardRedeem = () => {
|
|||||||
{!redeemSuccess ? (
|
{!redeemSuccess ? (
|
||||||
<>
|
<>
|
||||||
{/* 兑换说明 */}
|
{/* 兑换说明 */}
|
||||||
<View className="bg-blue-50 mx-4 mt-4 p-4 rounded-xl border border-blue-200">
|
<View className="bg-blue-500 mx-4 mt-4 p-4 rounded-xl border border-blue-200">
|
||||||
<View className="flex items-center mb-2">
|
<View className="flex items-center mb-2">
|
||||||
<Gift size="20" className="text-blue-600 mr-2" />
|
<Gift size="20" className="text-blue-600 mr-2" />
|
||||||
<Text className="font-semibold text-blue-800">兑换说明</Text>
|
<Text className="font-semibold text-blue-800">兑换说明</Text>
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ const GiftCardUse = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{gift.contactInfo && (
|
{gift.contactInfo && (
|
||||||
<View className="bg-blue-50 p-4 rounded-lg mb-6 border border-blue-200">
|
<View className="bg-blue-500 p-4 rounded-lg mb-6 border border-blue-200">
|
||||||
<Text className="text-blue-800 font-semibold mb-1">客服联系方式</Text>
|
<Text className="text-blue-800 font-semibold mb-1">客服联系方式</Text>
|
||||||
<Text className="text-blue-700">{gift.contactInfo}</Text>
|
<Text className="text-blue-700">{gift.contactInfo}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
4
src/user/notification/index.config.ts
Normal file
4
src/user/notification/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
navigationBarTitleText: '消息通知',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
})
|
||||||
206
src/user/notification/index.tsx
Normal file
206
src/user/notification/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Cell, CellGroup, Empty, InfiniteLoading, PullToRefresh, Tag, Button } from '@nutui/nutui-react-taro'
|
||||||
|
import { useThemeStyles } from '@/hooks/useTheme'
|
||||||
|
import { pageNotification, getUnreadCount, markNotificationRead, markAllNotificationRead } from '@/api/app/notification'
|
||||||
|
import type { Notification } from '@/api/app/notification/model'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
|
import 'dayjs/locale/zh-cn'
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime)
|
||||||
|
dayjs.locale('zh-cn')
|
||||||
|
|
||||||
|
/** 通知类型配置 */
|
||||||
|
const TYPE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
system: { label: '系统', color: '#3b82f6' },
|
||||||
|
ticket: { label: '工单', color: '#f59e0b' },
|
||||||
|
review: { label: '审核', color: '#8b5cf6' },
|
||||||
|
resource: { label: '资源', color: '#10b981' },
|
||||||
|
permission: { label: '权限', color: '#ef4444' },
|
||||||
|
member: { label: '成员', color: '#06b6d4' },
|
||||||
|
payment: { label: '支付', color: '#ec4899' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationPage = () => {
|
||||||
|
const themeStyles = useThemeStyles()
|
||||||
|
const [list, setList] = useState<Notification[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [unreadTotal, setUnreadTotal] = useState(0)
|
||||||
|
|
||||||
|
const loadNotifications = useCallback(async (isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const currentPage = isRefresh ? 1 : page
|
||||||
|
const res = await pageNotification({ page: currentPage, limit: 20 })
|
||||||
|
const newList = res?.list || []
|
||||||
|
if (isRefresh) {
|
||||||
|
setList(newList)
|
||||||
|
setPage(2)
|
||||||
|
} else {
|
||||||
|
setList(prev => [...prev, ...newList])
|
||||||
|
setPage(prev => prev + 1)
|
||||||
|
}
|
||||||
|
setHasMore(newList.length >= 20)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载通知失败', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [loading, page])
|
||||||
|
|
||||||
|
const loadUnreadCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUnreadCount()
|
||||||
|
setUnreadTotal(res?.total || 0)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
loadNotifications(true)
|
||||||
|
loadUnreadCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMarkRead = async (item: Notification) => {
|
||||||
|
if (item.isRead === 1 || !item.id) return
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
setList(prev => prev.map(n => n.id === item.id ? { ...n, isRead: 1 } : n))
|
||||||
|
loadUnreadCount()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('标记已读失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllNotificationRead()
|
||||||
|
setList(prev => prev.map(n => ({ ...n, isRead: 1 })))
|
||||||
|
setUnreadTotal(0)
|
||||||
|
Taro.showToast({ title: '已全部标记为已读', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('标记全部已读失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (item: Notification) => {
|
||||||
|
handleMarkRead(item)
|
||||||
|
if (item.linkUrl) {
|
||||||
|
// 外链通过 WebView 打开
|
||||||
|
Taro.navigateTo({ url: `/passport/webview/index?url=${encodeURIComponent(item.linkUrl)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return ''
|
||||||
|
return dayjs(time).fromNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<View className="bg-gray-100 min-h-screen">
|
||||||
|
<View className="px-4 py-6" style={themeStyles.primaryBackground}>
|
||||||
|
<Text className="text-white text-lg font-bold">消息通知</Text>
|
||||||
|
</View>
|
||||||
|
<Empty description="暂无通知" imageSize={80} className="mt-20" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-gray-100 min-h-screen">
|
||||||
|
{/* 头部 */}
|
||||||
|
<View className="px-4 py-6" style={themeStyles.primaryBackground}>
|
||||||
|
<View className="flex items-center justify-between">
|
||||||
|
<View>
|
||||||
|
<Text className="text-white text-lg font-bold">消息通知</Text>
|
||||||
|
{unreadTotal > 0 && (
|
||||||
|
<View className="mt-1">
|
||||||
|
<Text className="text-white text-sm opacity-80">{unreadTotal} 条未读</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{unreadTotal > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.18)',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.25)'
|
||||||
|
}}
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
>
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PullToRefresh onRefresh={() => loadNotifications(true)}>
|
||||||
|
{/* 通知列表 */}
|
||||||
|
<View className="mx-4 mt-4 rounded-xl overflow-hidden bg-white">
|
||||||
|
{list.map((item) => {
|
||||||
|
const typeConf = TYPE_CONFIG[item.type || ''] || TYPE_CONFIG.system
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={`px-4 py-3 border-b border-gray-50 ${item.isRead === 0 ? 'bg-blue-500' : ''}`}
|
||||||
|
onClick={() => handleClick(item)}
|
||||||
|
>
|
||||||
|
<View className="flex items-start gap-3">
|
||||||
|
{/* 未读标记 */}
|
||||||
|
<View className="mt-2">
|
||||||
|
{item.isRead === 0 ? (
|
||||||
|
<View className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<View className="w-2 h-2 bg-transparent" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<View className="flex-1 min-w-0">
|
||||||
|
<View className="flex items-center gap-2 mb-1">
|
||||||
|
<Tag type="primary" plain style={{ fontSize: '10px', padding: '0 4px' }}>
|
||||||
|
{typeConf.label}
|
||||||
|
</Tag>
|
||||||
|
<Text className={`text-sm ${item.isRead === 0 ? 'font-semibold text-gray-900' : 'text-gray-600'} truncate`}>
|
||||||
|
{item.title || '系统通知'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.content && (
|
||||||
|
<Text className="text-xs text-gray-400 line-clamp-2">{item.content}</Text>
|
||||||
|
)}
|
||||||
|
<View className="mt-1">
|
||||||
|
<Text className="text-xs text-gray-300">{formatTime(item.createTime)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 加载更多 */}
|
||||||
|
<InfiniteLoading
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={() => loadNotifications(false)}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<View className="py-4 text-center">
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{loading ? '加载中...' : hasMore ? '下拉加载更多' : '没有更多了'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</InfiniteLoading>
|
||||||
|
</PullToRefresh>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationPage
|
||||||
Reference in New Issue
Block a user