Compare commits

...

9 Commits

Author SHA1 Message Date
80e879a08c style(passport): 将授权页面主题切换为蓝色风格
- 新增蓝色主题扫描线与发光动画样式
- 添加不同颜色主题的粒子元素动画类
- 修改授权页面背景渐变为蓝色调
- 调整网格背景和渐变光晕颜色为蓝色系列
- 蓝色主题粒子背景与动画细节优化
- 更新按钮和文本渐变颜色为蓝色风格
- 按钮阴影和边框颜色修改为蓝色系
- 同意协议文本颜色切换为蓝色
- 底部指示点颜色替换为蓝色调,增强视觉统一性
2026-04-11 08:47:22 +08:00
ebb0ae76bb style(passport): 将授权页面主题切换为蓝色风格
- 新增蓝色主题扫描线与发光动画样式
- 添加不同颜色主题的粒子元素动画类
- 修改授权页面背景渐变为蓝色调
- 调整网格背景和渐变光晕颜色为蓝色系列
- 蓝色主题粒子背景与动画细节优化
- 更新按钮和文本渐变颜色为蓝色风格
- 按钮阴影和边框颜色修改为蓝色系
- 同意协议文本颜色切换为蓝色
- 底部指示点颜色替换为蓝色调,增强视觉统一性
2026-04-11 08:46:19 +08:00
0b5f8293e5 style(passport): 将授权页面主题切换为蓝色风格
- 新增蓝色主题扫描线与发光动画样式
- 添加不同颜色主题的粒子元素动画类
- 修改授权页面背景渐变为蓝色调
- 调整网格背景和渐变光晕颜色为蓝色系列
- 蓝色主题粒子背景与动画细节优化
- 更新按钮和文本渐变颜色为蓝色风格
- 按钮阴影和边框颜色修改为蓝色系
- 同意协议文本颜色切换为蓝色
- 底部指示点颜色替换为蓝色调,增强视觉统一性
2026-04-11 08:42:42 +08:00
ad6fb13ec1 refactor(qr-confirm): 简化二维码确认登录页面逻辑
- 移除未使用的组件和图标导入,减少包大小
- 注释掉登录卡片相关样式和元素,隐藏界面内容
- 删除手动确认登录函数及相关按钮渲染逻辑
- 去除状态图标、标题、描述和用户信息的渲染函数
- 删除所有操作按钮和安全提示组件渲染
- 取消整体页面布局,仅直接渲染授权页面部分
- 修改品牌文本首字母小写,统一风格
2026-04-11 08:35:31 +08:00
2fd6f073c3 refactor(qr-confirm): 简化二维码确认登录页面逻辑
- 移除未使用的组件和图标导入,减少包大小
- 注释掉登录卡片相关样式和元素,隐藏界面内容
- 删除手动确认登录函数及相关按钮渲染逻辑
- 去除状态图标、标题、描述和用户信息的渲染函数
- 删除所有操作按钮和安全提示组件渲染
- 取消整体页面布局,仅直接渲染授权页面部分
- 修改品牌文本首字母小写,统一风格
2026-04-11 08:30:31 +08:00
4507cd484e style(rider): 优化骑手中心界面及功能入口样式
- 添加骑手订单统计数据,支持待取货、配送中、已完成与今日任务
- 增加刷新按钮,便捷更新统计数据
- 优化头像及用户信息展示,增加主题样式和背景装饰
- 新增快捷功能入口,展示待取货订单数量红点提醒
- 登录未授权时提示并引导登录
- 调整色彩风格,将多处组件背景色由浅蓝色调整为深蓝色,提高视觉一致性
- 管理页新增内容管理工具的基础UI布局
- 扫码确认页重构授权登录页面,改用科技风格设计,增加动态粒子动画和渐变光晕效果
- 协议打开方式改为跳转网页视图,移除旧版弹窗
- 修正部分组件样式,调整按钮及文本颜色,提升视觉效果和交互体验
2026-04-10 19:36:37 +08:00
d87e9d3f13 refactor(passport): 优化二维码确认登录页面流程和界面
- 移除根据扫码参数自动确认登录逻辑,改为统一显示授权登录界面
- 简化用户登录判断,统一走手机号授权登录流程,提升用户体验
- 替换按钮组件由 TaroButton 改为 NutUI Button,统一样式风格
- 更新授权登录页面 UI,调整背景、品牌名和标语内容
- 优化手机号授权登录按钮样式,使用渐变色背景和中心对齐
2026-04-08 02:35:17 +08:00
43f5bee5b1 fix(passport): 解决扫码登录手机号绑定逻辑问题
- 增加用户是否绑定手机号的判断逻辑
- 未绑定手机号时展示授权界面,避免直接登录
- 已绑定手机号时继续确认登录流程
- 优化用户信息保存时机和条件
- 调整控制台日志输出,明确用户状态反馈
2026-04-08 02:07:57 +08:00
28102fb0bd feat(passport): 改造扫码登录确认页面授权登录界面及协议功能
- 重新设计授权登录页面UI,参考权大师风格,包含顶部标题、Logo与品牌名
- 添加手机号授权登录按钮及取消按钮,优化用户交互体验
- 新增服务协议和隐私政策弹窗,支持点击查看详细内容
- 增加协议勾选状态管理,未勾选协议时授权提示用户
- 修改登录确认流程,根据用户注册状态分别处理自动确认与手机号授权
- 优化按钮、状态提示和页面布局,提升整体界面一致性和可用性
2026-04-08 02:03:10 +08:00
34 changed files with 1496 additions and 684 deletions

View File

@@ -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": 1775583343042 "lastUpdated": 1775868406858
} }

View 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 自动注册)

View 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

View File

@@ -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
}

View File

@@ -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
}
]

View File

@@ -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
}
]

View File

@@ -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

View File

@@ -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>
) )
} }

View 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));
}

View 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',
}

View 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));
}

View 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;
}

View 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));
}

View 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
}

View File

@@ -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"
] ]
}, },
{ {

View File

@@ -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;
}

View File

@@ -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?.();

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>*/}
</> </>
) )
} }

View File

@@ -1,32 +1,13 @@
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 { loginByOpenId } from '@/api/passport/wx-login';
import { TenantId } from "@/config/app";
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server"; import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
import { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite"; 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-08 更新):
* 1. 用户扫码 → 进入 qr-confirm 页面
* 2. 页面立即调用 wx.login() 获取 code
* 3. 用 code 调用 /api/wx-login/loginByOpenId 获取/验证用户身份
* 4. 如果用户不存在 → 显示微信手机号授权按钮(一键注册登录)
* 5. 如果用户存在 → 自动调用 confirmQRLogin 确认登录
*/ */
// 微信获取手机号回调参数类型 // 微信获取手机号回调参数类型
@@ -51,142 +32,68 @@ interface LoginResponse {
message: string; message: string;
} }
// 协议类型
type AgreementType = 'service' | 'privacy';
const QRConfirmPage: React.FC = () => { const QRConfirmPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string>('');
const [token, setToken] = useState<string>('');
const [loginMethod, setLoginMethod] = useState<'scan' | 'url'>('url');
const [userInfo, setUserInfo] = useState<any>(null);
const [needAuth, setNeedAuth] = useState(false); // 是否需要手机号授权
const [authLoading, setAuthLoading] = useState(false); // 授权中状态 const [authLoading, setAuthLoading] = useState(false); // 授权中状态
const [agreementChecked, setAgreementChecked] = useState(false); // 协议勾选状态
const [token, setToken] = useState<string>(''); // 登录 token
useEffect(() => { useEffect(() => {
// 从 URL 参数中获取 token // 从 URL 参数中获取 token
const params = router.params; const params = router.params;
// 兼容多种参数名
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);
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);
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);
// 扫码场景:自动确认登录
if (params.scene || params.token || params.qrCodeKey || params.q) {
console.log('[QRConfirm] 检测到扫码参数,自动确认登录');
setTimeout(() => {
handleAutoConfirm(loginToken);
}, 500);
}
} else {
setError('无效的登录链接');
}
}, [router.params]); }, [router.params]);
/**
* 自动确认登录URL 扫码场景)
*/
const handleAutoConfirm = async (loginToken: string) => {
try {
setLoading(true);
// 1. 调用微信登录获取 code
console.log('[QRConfirm] 调用 wx.login() 获取 code...');
const loginResult = await Taro.login();
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: 5
});
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] 用户未注册,显示手机号授权界面');
setNeedAuth(true);
setLoading(false);
}
} catch (err: any) {
console.error('[QRConfirm] 自动确认登录失败:', err);
setError(err.message || '自动确认登录失败');
setLoading(false);
}
};
/** /**
* 处理微信手机号授权 * 处理微信手机号授权
*/ */
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => { const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
const { code, encryptedData, iv, errMsg } = detail; const { code, encryptedData, iv, errMsg } = detail;
// 检查协议是否勾选
if (!agreementChecked) {
Taro.showToast({ title: '请先同意服务协议和隐私政策', icon: 'none' });
return;
}
// 用户拒绝授权 // 用户拒绝授权
if (errMsg && errMsg.includes('fail')) { if (errMsg && errMsg.includes('fail')) {
Taro.showToast({ Taro.showToast({ title: '需要授权手机号才能完成登录', icon: 'none' });
title: '需要授权手机号才能完成登录',
icon: 'none'
});
return; return;
} }
if (!code) { if (!code) {
Taro.showToast({ Taro.showToast({ title: '获取授权信息失败,请重试', icon: 'none' });
title: '获取授权信息失败,请重试',
icon: 'none'
});
return; return;
} }
// 执行授权登录
await handleAuthLogin(code, encryptedData, iv); await handleAuthLogin(code, encryptedData, iv);
}; };
/** /**
* 授权登录(未注册用户) * 授权登录
*/ */
const handleAuthLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => { const handleAuthLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => {
try { try {
setAuthLoading(true); setAuthLoading(true);
// 获取存储的邀请参数 // 获取邀请参数
const inviteParams = getStoredInviteParams(); const inviteParams = getStoredInviteParams();
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0; const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0;
@@ -197,143 +104,80 @@ const QRConfirmPage: React.FC = () => {
code: phoneCode, code: phoneCode,
encryptedData, encryptedData,
iv, iv,
tenantId: TenantId, tenantId: 5,
notVerifyPhone: true, notVerifyPhone: true,
refereeId: refereeId, refereeId,
sceneType: 'save_referee' sceneType: 'save_referee'
}, },
header: { header: { 'content-type': 'application/json', 'TenantId': 5 }
'content-type': 'application/json',
'TenantId': TenantId
}
}); });
if (res.data.code !== 0) { if (res.data.code !== 0) {
throw new Error(res.data.message || '登录失败'); throw new Error(res.data.message || '登录失败');
} }
// 保存登录信息
if (res.data.data?.user) { if (res.data.data?.user) {
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user); saveStorageByLoginUser(res.data.data.access_token, res.data.data.user);
setUserInfo(res.data.data.user);
// 处理邀请关系 // 处理邀请关系
if (hasPendingInvite()) { if (hasPendingInvite()) {
try { try {
await checkAndHandleInviteRelation(); await checkAndHandleInviteRelation();
} catch (e) { } catch (e) {
console.error('授权登录后处理邀请关系失败:', e); console.error('处理邀请关系失败:', e);
} }
} }
Taro.showToast({ Taro.showToast({ title: '授权成功,正在确认登录...', icon: 'none' });
title: '授权成功,正在确认登录...',
icon: 'success'
});
// 延迟后自动确认扫码登录 // 延迟确认扫码登录
setTimeout(() => { setTimeout(() => handleConfirmQRLogin(res.data.data.user), 1500);
handleConfirmLogin(token, res.data.data.user);
}, 1500);
} }
} catch (error: any) { } catch (error: any) {
Taro.showToast({ Taro.showToast({ title: error.message || '授权失败', icon: 'error' });
title: error.message || '授权失败',
icon: 'error'
});
} finally { } finally {
setAuthLoading(false); setAuthLoading(false);
} }
}; };
/** /**
* 确认登录 * 确认扫码登录
*/ */
const handleConfirmLogin = async (loginToken?: string, wxUserInfo?: any) => { const handleConfirmQRLogin = async (userInfo: any) => {
const confirmToken = loginToken || token; if (!token) {
Taro.showToast({ title: '缺少登录token', icon: 'none' });
if (!confirmToken) {
setError('缺少登录token');
return; return;
} }
const currentUser = wxUserInfo || userInfo;
if (!currentUser?.userId) {
const userId = Taro.getStorageSync('UserId');
if (!userId) {
setError('请先登录小程序');
setNeedAuth(true);
return;
}
currentUser && (currentUser.userId = Number(userId));
}
try { try {
setLoading(true); const res = await Taro.request({
setError(''); url: `${SERVER_API_URL}/qr-login/confirm`,
method: 'POST',
const result = await confirmQRLogin({ data: {
token: confirmToken, token,
userId: currentUser.userId, userId: userInfo.userId,
platform: 'wechat', platform: 'wechat',
wechatInfo: { wechatInfo: {
nickname: currentUser.nickname || currentUser.username, nickname: userInfo.nickname || userInfo.username,
avatar: currentUser.avatar avatar: userInfo.avatar
} }
},
header: { 'content-type': 'application/json', 'TenantId': 5 }
}); });
const isConfirmed = result.status === 'confirmed' || result.success === true; if (res.data.success || res.data.status === 'confirmed') {
Taro.showToast({ title: '登录确认成功', icon: 'success', duration: 1500 });
if (isConfirmed) {
setConfirmed(true);
setNeedAuth(false);
Taro.showToast({
title: result.successMessage || result.message || '登录确认成功',
icon: 'success',
duration: 2000
});
setTimeout(() => { setTimeout(() => {
const pages = Taro.getCurrentPages(); Taro.switchTab({ url: '/pages/user/user' });
if (pages.length > 1) {
Taro.navigateBack();
} else {
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); }, 1500);
} else { } else {
setError(result.message || '登录确认失败'); Taro.showToast({ title: res.data.message || '登录确认失败', icon: 'none' });
} }
} catch (err: any) { } catch (err: any) {
console.error('[QRConfirm] 确认登录失败:', err); Taro.showToast({ title: err.message || '确认登录失败', icon: 'error' });
setError(err.message || '登录确认失败');
} finally {
setLoading(false);
} }
}; };
/**
* 手动确认登录(主动扫码场景)
*/
const handleManualConfirm = () => {
handleConfirmLogin();
};
/** /**
* 取消登录 * 取消登录
*/ */
@@ -342,358 +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); };
setNeedAuth(false); const targetUrl = encodeURIComponent(urlMap[type]);
if (token) { Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` });
handleAutoConfirm(token);
}
}; };
/** // 科技风格授权页面 - 蓝色主题
* 打开微信扫码 return (
*/ <View className="min-h-screen relative overflow-hidden" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
const handleScan = () => {
Taro.scanCode({
success: async (res) => {
console.log('[QRConfirm] 扫码成功:', res);
let scanToken = ''; {/* 背景科技元素 */}
const qrContent = res.result; {/* 网格背景 */}
<View style={{
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,
}} />
try { {/* 渐变光晕 - 左上 */}
if (qrContent.includes('http')) { <View style={{
const url = new URL(qrContent); position: 'absolute', top: '-30%', left: '-20%', width: '60%', height: '60%',
scanToken = url.searchParams.get('token') || background: 'radial-gradient(circle, rgba(59, 130, 246, 0.25) 0%, transparent 70%)',
url.searchParams.get('qrCodeKey') || filter: 'blur(40px)',
''; }} />
}
if (!scanToken && qrContent.startsWith('{')) { {/* 渐变光晕 - 右下 */}
const parsed = JSON.parse(qrContent); <View style={{
scanToken = parsed.token || parsed.qrCodeKey || ''; 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)',
}} />
if (!scanToken && qrContent.length >= 32) { {/* 动态粒子光点 */}
scanToken = qrContent; {[
} { 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,
}} />
))}
if (scanToken) { {/* 扫描线效果 */}
setToken(scanToken); <View style={{
setLoginMethod('scan'); position: 'absolute', top: 0, left: 0, right: 0, height: '2px',
setNeedAuth(false); background: 'linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.6), transparent)',
setError(''); animation: 'scanline-blue 3s ease-in-out infinite',
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 renderStatusIcon = () => { <View className="relative z-10 flex flex-col items-center justify-center min-h-screen px-8">
if (loading || authLoading) return (
<View className="mb-6"> {/* Logo 区域 */}
<View className="w-16 h-16 mx-auto flex items-center justify-center"> <View className="flex flex-col items-center mb-12">
<Loading className="text-blue-500" /> <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>
<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>
</View>
);
if (confirmed) return ( {/* 主按钮 - 渐变发光按钮 */}
<View className="mb-6"> <View style={{
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center"> width: '100%', maxWidth: '320px',
<Success className="text-green-500" size="32" /> background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 50%, #6366f1 100%)',
</View> borderRadius: '30px', padding: '2px',
</View> boxShadow: '0 0 30px rgba(59, 130, 246, 0.4), 0 0 60px rgba(59, 130, 246, 0.2)',
); }}>
if (error && !needAuth) return (
<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>
);
if (needAuth) return (
<View className="mb-6">
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="#22c55e">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>
</View>
</View>
);
return (
<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 || authLoading) return '正在处理...';
if (confirmed) return '登录确认成功';
if (needAuth) return '首次登录授权';
if (error) return '登录确认失败';
return loginMethod === 'url' ? '扫码登录确认' : '确认登录';
};
// 获取描述
const getDescription = () => {
if (loading) return '请稍候,正在为您确认登录';
if (authLoading) return '正在授权登录...';
if (confirmed) return '您已成功确认登录,网页端将自动登录';
if (needAuth) return '检测到您是首次使用,请授权手机号完成注册并登录';
if (error) return error;
if (loginMethod === 'url') {
return '检测到登录请求,是否确认登录?';
}
const displayName = userInfo?.nickname || userInfo?.username || '当前用户';
return `确认使用 ${displayName} 登录网页端?`;
};
// 渲染操作按钮
const renderActions = () => {
// 需要授权登录
if (needAuth) {
return (
<View className="space-y-3">
<Button <Button
type="success"
size="large"
className="w-full rounded-xl"
open-type="getPhoneNumber" open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber} onGetPhoneNumber={handleGetPhoneNumber}
disabled={authLoading} 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 ? '授权中...' : '微信手机号一键授权'} {authLoading ? '授权中...' : '微信手机号登录'}
</Button>
<Button
type="default"
size="large"
onClick={handleCancel}
className="w-full rounded-xl"
fill="none"
>
</Button> </Button>
</View> </View>
);
}
if (loading) { {/* 取消按钮 */}
return ( <View style={{ width: '100%', maxWidth: '320px', height: '44px', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: '16px' }} onClick={handleCancel}>
<Button <Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '14px' }}></Text>
type="default"
size="large"
disabled
className="w-full rounded-xl"
>
...
</Button>
);
}
if (confirmed) {
return (
<Button
type="success"
size="large"
onClick={handleCancel}
className="w-full rounded-xl"
>
</Button>
);
}
if (error) {
return (
<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>
);
}
if (loginMethod === 'scan') {
return (
<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>
);
}
return (
<Button
type="primary"
size="large"
onClick={handleManualConfirm}
className="w-full rounded-xl"
>
</Button>
);
};
return (
<View className="qr-confirm-page min-h-screen bg-gradient-to-b from-blue-50 to-white">
<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">
<Text className="text-3xl">🔐</Text>
</View>
<Text className="text-gray-400 text-sm">WebSoft Platform</Text>
</View> </View>
{/* 主要内容卡片 */} {/* 协议勾选 */}
<Card className="bg-white rounded-2xl shadow-xl -mt-4"> <View className="flex items-center justify-center mt-6">
<View className="p-6 text-center"> <View style={{ padding: '8px', marginRight: '4px' }} onClick={() => setAgreementChecked(!agreementChecked)}>
{/* 状态图标 */} <View style={{
{renderStatusIcon()} width: '18px', height: '18px', borderRadius: '4px',
border: agreementChecked ? 'none' : '1px solid rgba(255, 255, 255, 0.3)',
{/* 标题 */} background: agreementChecked ? 'linear-gradient(135deg, #1d4ed8, #3b82f6)' : 'transparent',
<Text className="text-xl font-bold text-gray-800 mb-2 block"> display: 'flex', alignItems: 'center', justifyContent: 'center',
{getTitle()} }}>
</Text> {agreementChecked && <Text style={{ color: '#fff', fontSize: '12px' }}></Text>}
{/* 描述 */}
<Text className="text-gray-600 mb-6 block text-sm">
{getDescription()}
</Text>
{/* 用户信息 */}
{!loading && !confirmed && !error && !needAuth && userInfo && (
<View className="bg-gray-50 rounded-xl p-4 mb-6">
<View className="flex items-center justify-center">
{userInfo.avatar ? (
<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 && !needAuth && (
<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>
)}
{/* 操作按钮 */}
{renderActions()}
</View>
</Card>
{/* 安全提示 */}
{!needAuth && (
<Card className="bg-yellow-50 border border-yellow-200 rounded-xl mt-4">
<View className="p-4">
<View className="flex items-start">
<Tips className="text-yellow-600 mr-2 mt-1" size="16" />
<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> </View>
</Card> </View>
)} <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>
{/* 底部说明 */} {/* 底部装饰 */}
<View className="text-center mt-6 pb-8"> <View style={{ position: 'absolute', bottom: '40px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Text className="text-xs text-gray-400"> <View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#3b82f6', boxShadow: '0 0 10px rgba(59, 130, 246, 0.8)' }} />
<Text style={{ color: 'rgba(255, 255, 255, 0.3)', fontSize: '11px' }}></Text>
</Text> <View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#6366f1', boxShadow: '0 0 10px rgba(99, 102, 241, 0.8)' }} />
</View> </View>
</View> </View>
</View> </View>

View File

@@ -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" />

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '详情',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
};

View 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;

View File

@@ -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

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
export default defineAppConfig({
navigationBarTitleText: '我的应用',
})

192
src/user/apps/index.tsx Normal file
View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
export default defineAppConfig({
navigationBarTitleText: '消息通知',
navigationBarBackgroundColor: '#ffffff',
})

View 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