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,
|
||||
"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 {useUser} from "@/hooks/useUser";
|
||||
import {Empty} from '@nutui/nutui-react-taro';
|
||||
import {Text} from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { ConfigProvider, Grid } from '@nutui/nutui-react-taro'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useThemeStyles } from '@/hooks/useTheme'
|
||||
import { Edit } from '@nutui/icons-react-taro'
|
||||
|
||||
function Admin() {
|
||||
const {
|
||||
isAdmin
|
||||
} = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
}, []);
|
||||
const { isAdmin } = useUser()
|
||||
const themeStyles = useThemeStyles()
|
||||
|
||||
if (!isAdmin()) {
|
||||
return (
|
||||
<Empty
|
||||
description="您不是管理员"
|
||||
imageSize={80}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
height: 'calc(100vh - 200px)'
|
||||
}}
|
||||
>
|
||||
|
||||
</Empty>
|
||||
);
|
||||
<View className="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||
<View className="text-center">
|
||||
<Text className="text-gray-400 text-sm">暂无访问权限</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>待开发...</Text>
|
||||
</>
|
||||
<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>
|
||||
|
||||
<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",
|
||||
'qr-login/index',
|
||||
'qr-confirm/index',
|
||||
'unified-qr/index'
|
||||
'unified-qr/index',
|
||||
'webview/index'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -65,7 +66,9 @@ export default {
|
||||
"chat/conversation/index",
|
||||
"chat/message/index",
|
||||
"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;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
line-height: inherit !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
button[open-type="chooseAvatar"] {
|
||||
background: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
line-height: inherit !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.buy-btn{
|
||||
@@ -101,3 +97,137 @@ image {
|
||||
.admin-feature-item:active {
|
||||
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: '统一扫码',
|
||||
description: '扫码登录和核销一体化功能',
|
||||
icon: <Scan className="text-blue-500" size="24" />,
|
||||
color: 'bg-blue-50 border-blue-200',
|
||||
color: 'bg-blue-500 border-blue-200',
|
||||
onClick: () => {
|
||||
navTo('/passport/unified-qr/index', true);
|
||||
onClose?.();
|
||||
|
||||
@@ -211,7 +211,7 @@ const DealerIndex: React.FC = () => {
|
||||
>
|
||||
<Grid.Item text="分销订单" onClick={() => navigateToPage('/dealer/orders/index')}>
|
||||
<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"/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -137,7 +137,7 @@ const InviteStatsPage: React.FC = () => {
|
||||
</View>
|
||||
) : inviteStats ? (
|
||||
<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" />
|
||||
<Text className="text-2xl font-bold text-blue-600">
|
||||
{inviteStats.totalInvites || 0}
|
||||
|
||||
@@ -211,7 +211,7 @@ const DealerIndex: React.FC = () => {
|
||||
>
|
||||
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
|
||||
<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"/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -7,13 +7,12 @@ import {
|
||||
Location,
|
||||
Tips,
|
||||
Ask,
|
||||
// Dongdong,
|
||||
People,
|
||||
// AfterSaleService,
|
||||
Logout,
|
||||
Shop,
|
||||
Jdl,
|
||||
Service
|
||||
Service,
|
||||
Message,
|
||||
Apps
|
||||
} from '@nutui/icons-react-taro'
|
||||
import {useUser} from "@/hooks/useUser";
|
||||
|
||||
@@ -26,7 +25,6 @@ const UserCell = () => {
|
||||
content: '确定要退出登录吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
// 使用 useUser hook 的 logoutUser 方法
|
||||
logoutUser();
|
||||
Taro.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
@@ -54,7 +52,7 @@ const UserCell = () => {
|
||||
{hasRole('store') && (
|
||||
<Grid.Item text="门店中心" onClick={() => navTo('/store/index', 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">
|
||||
<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"/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -62,10 +60,10 @@ const UserCell = () => {
|
||||
)}
|
||||
|
||||
{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="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
<Jdl color="#3b82f6" size="20"/>
|
||||
<View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
<Apps color="#6366f1" size="20"/>
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
@@ -81,6 +79,22 @@ const UserCell = () => {
|
||||
</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)}>
|
||||
<View className="text-center">
|
||||
<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>
|
||||
</Grid.Item>
|
||||
|
||||
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
|
||||
<Grid.Item text="常见问题" onClick={() => navTo('/user/help/index')}>
|
||||
<View className="text-center">
|
||||
<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>
|
||||
</Grid.Item>
|
||||
|
||||
{/* 修改联系我们为微信客服 */}
|
||||
<Grid.Item text="联系我们">
|
||||
<Button
|
||||
open-type="contact"
|
||||
@@ -105,13 +118,13 @@ const UserCell = () => {
|
||||
hover-class="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"/>
|
||||
</View>
|
||||
</Button>
|
||||
</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="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
<ShieldCheck color="#10b981" size="20"/>
|
||||
@@ -119,7 +132,7 @@ const UserCell = () => {
|
||||
</View>
|
||||
</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="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
<People color="#8b5cf6" size="20"/>
|
||||
@@ -127,35 +140,18 @@ const UserCell = () => {
|
||||
</View>
|
||||
</Grid.Item>
|
||||
|
||||
{/*<Grid.Item text={'我的邀请码'} onClick={() => navTo('/dealer/qrcode/index', true)}>*/}
|
||||
{/* <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')}>
|
||||
<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"/>
|
||||
<Tips className="text-amber-500" size="20"/>
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
|
||||
<Grid.Item text={'安全退出'} onClick={onLogout}>
|
||||
<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"/>
|
||||
<Logout className="text-pink-500" size="20"/>
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
@@ -163,53 +159,6 @@ const UserCell = () => {
|
||||
</Grid>
|
||||
</ConfigProvider>
|
||||
</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 Taro, { useRouter } from '@tarojs/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 { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
|
||||
|
||||
@@ -183,6 +183,10 @@ const PhoneAuthLogin = () => {
|
||||
try {
|
||||
setLoading(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',
|
||||
@@ -190,7 +194,10 @@ const PhoneAuthLogin = () => {
|
||||
code: phoneCode,
|
||||
encryptedData,
|
||||
iv,
|
||||
tenantId: TenantId
|
||||
tenantId: TenantId,
|
||||
notVerifyPhone: true, // 用户未注册时自动注册
|
||||
refereeId: refereeId, // 推荐人ID
|
||||
sceneType: 'save_referee'
|
||||
},
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
@@ -266,7 +273,7 @@ const PhoneAuthLogin = () => {
|
||||
{/* 微信授权按钮 */}
|
||||
<div className="w-full mb-8">
|
||||
<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"
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
disabled={loading}
|
||||
@@ -290,9 +297,7 @@ const PhoneAuthLogin = () => {
|
||||
{/* 协议勾选 */}
|
||||
<div className="flex items-start mb-6 px-2">
|
||||
<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 ${
|
||||
agreed ? 'bg-blue-500 border-blue-500' : 'border-gray-300 bg-white hover:border-blue-400'
|
||||
}`}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 mt-1 flex-shrink-0 cursor-pointer transition-colors`}
|
||||
onClick={() => setAgreed(!agreed)}
|
||||
>
|
||||
{agreed && (
|
||||
@@ -304,7 +309,7 @@ const PhoneAuthLogin = () => {
|
||||
<div className="text-sm text-gray-600">
|
||||
我已阅读并同意
|
||||
<span
|
||||
className="text-blue-500 cursor-pointer hover:text-blue-600"
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowAgreement(true);
|
||||
@@ -314,7 +319,7 @@ const PhoneAuthLogin = () => {
|
||||
</span>
|
||||
和
|
||||
<span
|
||||
className="text-blue-500 cursor-pointer hover:text-blue-600"
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPrivacy(true);
|
||||
|
||||
@@ -1,259 +1,183 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Button, Loading, Card } from '@nutui/nutui-react-taro';
|
||||
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
||||
import { View, Text, Button } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { confirmQRLogin } from '@/api/passport/qr-login';
|
||||
import { loginByOpenId } from '@/api/passport/wx-login';
|
||||
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
|
||||
import { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite";
|
||||
|
||||
/**
|
||||
* 扫码登录确认页面
|
||||
* 扫码登录确认页面 - 科技风格授权页
|
||||
*
|
||||
* 支持两种场景:
|
||||
* 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 确认登录
|
||||
* 用户扫描 PC 端二维码后,打开此小程序页面进行微信手机号授权登录
|
||||
*/
|
||||
|
||||
// 微信获取手机号回调参数类型
|
||||
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 router = useRouter();
|
||||
// 移除 useUser 依赖,改用 wx.login() + loginByOpenId 方式验证用户身份
|
||||
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 [authLoading, setAuthLoading] = useState(false); // 授权中状态
|
||||
const [agreementChecked, setAgreementChecked] = useState(false); // 协议勾选状态
|
||||
const [token, setToken] = useState<string>(''); // 登录 token
|
||||
|
||||
useEffect(() => {
|
||||
// 从 URL 参数中获取 token
|
||||
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 || '';
|
||||
|
||||
// 如果是 q 参数(URL 编码的完整 URL),需要解析
|
||||
// 兼容 q 参数(URL 编码的完整 URL)
|
||||
if (params.q && !loginToken) {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(params.q);
|
||||
console.log('[QRConfirm] 解码后的 URL:', decodedUrl);
|
||||
|
||||
// 解析 token
|
||||
const url = new URL(decodedUrl);
|
||||
loginToken = url.searchParams.get('token') ||
|
||||
url.searchParams.get('qrCodeKey') ||
|
||||
'';
|
||||
|
||||
setLoginMethod('url');
|
||||
loginToken = url.searchParams.get('token') || url.searchParams.get('qrCodeKey') || '';
|
||||
} catch (e) {
|
||||
console.error('[QRConfirm] 解析 q 参数失败:', e);
|
||||
// 尝试直接使用 q 作为 token
|
||||
loginToken = decodeURIComponent(params.q);
|
||||
setLoginMethod('url');
|
||||
}
|
||||
} else if (loginToken) {
|
||||
setLoginMethod('url');
|
||||
}
|
||||
|
||||
if (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]);
|
||||
|
||||
/**
|
||||
* 自动确认登录(URL 扫码场景)
|
||||
*
|
||||
* 新的登录流程(2026-04-07):
|
||||
* 1. 调用 wx.login() 获取 code
|
||||
* 2. 用 code 调用后端 loginByOpenId 接口验证用户身份
|
||||
* 3. 用户存在 → 调用 confirmQRLogin 确认登录
|
||||
* 4. 用户不存在 → 跳转到用户页引导注册
|
||||
* 处理微信手机号授权
|
||||
*/
|
||||
const handleAutoConfirm = async (loginToken: string) => {
|
||||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||
const { code, encryptedData, iv, errMsg } = detail;
|
||||
|
||||
// 检查协议是否勾选
|
||||
if (!agreementChecked) {
|
||||
Taro.showToast({ title: '请先同意服务协议和隐私政策', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户拒绝授权
|
||||
if (errMsg && errMsg.includes('fail')) {
|
||||
Taro.showToast({ title: '需要授权手机号才能完成登录', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
Taro.showToast({ title: '获取授权信息失败,请重试', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
await handleAuthLogin(code, encryptedData, iv);
|
||||
};
|
||||
|
||||
/**
|
||||
* 授权登录
|
||||
*/
|
||||
const handleAuthLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setAuthLoading(true);
|
||||
|
||||
// 1. 调用微信登录获取 code
|
||||
console.log('[QRConfirm] 调用 wx.login() 获取 code...');
|
||||
const loginResult = await Taro.login();
|
||||
// 获取邀请参数
|
||||
const inviteParams = getStoredInviteParams();
|
||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0;
|
||||
|
||||
if (!loginResult.code) {
|
||||
throw new Error('获取微信登录凭证失败');
|
||||
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 }
|
||||
});
|
||||
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message || '登录失败');
|
||||
}
|
||||
console.log('[QRConfirm] 获取到 code:', loginResult.code);
|
||||
|
||||
// 2. 用 code 调用后端接口验证用户身份
|
||||
console.log('[QRConfirm] 调用后端 loginByOpenId...');
|
||||
const wxLoginResult = await loginByOpenId({
|
||||
code: loginResult.code,
|
||||
tenantId: 10398 // 使用固定租户ID
|
||||
});
|
||||
if (res.data.data?.user) {
|
||||
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user);
|
||||
|
||||
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);
|
||||
// 处理邀请关系
|
||||
if (hasPendingInvite()) {
|
||||
try {
|
||||
await checkAndHandleInviteRelation();
|
||||
} catch (e) {
|
||||
console.error('处理邀请关系失败:', e);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[QRConfirm] 自动确认登录失败:', err);
|
||||
setError(err.message || '自动确认登录失败');
|
||||
}
|
||||
|
||||
Taro.showToast({ title: '授权成功,正在确认登录...', icon: 'none' });
|
||||
|
||||
// 延迟确认扫码登录
|
||||
setTimeout(() => handleConfirmQRLogin(res.data.data.user), 1500);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Taro.showToast({ title: error.message || '授权失败', icon: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认登录
|
||||
* @param loginToken - 可选的登录token,默认使用页面token
|
||||
* @param wxUserInfo - 微信登录获取的用户信息(可选)
|
||||
* 确认扫码登录
|
||||
*/
|
||||
const handleConfirmLogin = async (loginToken?: string, wxUserInfo?: any) => {
|
||||
const confirmToken = loginToken || token;
|
||||
|
||||
if (!confirmToken) {
|
||||
setError('缺少登录token');
|
||||
const handleConfirmQRLogin = async (userInfo: any) => {
|
||||
if (!token) {
|
||||
Taro.showToast({ title: '缺少登录token', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先使用传入的用户信息,否则尝试从本地存储获取
|
||||
const currentUser = wxUserInfo || userInfo;
|
||||
|
||||
if (!currentUser?.userId) {
|
||||
// 没有用户信息,尝试从本地存储获取
|
||||
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 {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const result = await confirmQRLogin({
|
||||
token: confirmToken,
|
||||
userId: currentUser.userId,
|
||||
const res = await Taro.request({
|
||||
url: `${SERVER_API_URL}/qr-login/confirm`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
token,
|
||||
userId: userInfo.userId,
|
||||
platform: 'wechat',
|
||||
wechatInfo: {
|
||||
nickname: currentUser.nickname || currentUser.username,
|
||||
avatar: currentUser.avatar
|
||||
nickname: userInfo.nickname || userInfo.username,
|
||||
avatar: userInfo.avatar
|
||||
}
|
||||
},
|
||||
header: { 'content-type': 'application/json', 'TenantId': 5 }
|
||||
});
|
||||
|
||||
// 根据 status 判断成功:confirmed 表示登录成功
|
||||
const isConfirmed = result.status === 'confirmed' || result.success === true;
|
||||
|
||||
if (isConfirmed) {
|
||||
setConfirmed(true);
|
||||
Taro.showToast({
|
||||
title: result.successMessage || result.message || '登录确认成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
|
||||
// 3秒后自动关闭或返回
|
||||
if (res.data.success || res.data.status === 'confirmed') {
|
||||
Taro.showToast({ title: '登录确认成功', icon: 'success', duration: 1500 });
|
||||
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' });
|
||||
Taro.switchTab({ url: '/pages/user/user' });
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(result.message || '登录确认失败');
|
||||
Taro.showToast({ title: res.data.message || '登录确认失败', icon: 'none' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[QRConfirm] 确认登录失败:', err);
|
||||
setError(err.message || '登录确认失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.showToast({ title: err.message || '确认登录失败', icon: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 手动确认登录(主动扫码场景)
|
||||
*/
|
||||
const handleManualConfirm = () => {
|
||||
handleConfirmLogin();
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消登录
|
||||
*/
|
||||
@@ -262,298 +186,159 @@ const QRConfirmPage: React.FC = () => {
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
} else {
|
||||
Taro.switchTab({
|
||||
url: '/pages/user/user'
|
||||
});
|
||||
Taro.switchTab({ url: '/pages/user/user' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重试
|
||||
*/
|
||||
const handleRetry = () => {
|
||||
setError('');
|
||||
setConfirmed(false);
|
||||
handleConfirmLogin();
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开微信扫码
|
||||
*/
|
||||
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} 登录网页端?`;
|
||||
// 打开协议页面
|
||||
const openAgreement = (type: AgreementType) => {
|
||||
const urlMap = {
|
||||
service: 'https://websopy.websoft.top/agreement',
|
||||
privacy: 'https://websopy.websoft.top/privacy',
|
||||
};
|
||||
const targetUrl = encodeURIComponent(urlMap[type]);
|
||||
Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` });
|
||||
};
|
||||
|
||||
// 科技风格授权页面 - 蓝色主题
|
||||
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 className="min-h-screen relative overflow-hidden" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
|
||||
|
||||
{/* 背景科技元素 */}
|
||||
{/* 网格背景 */}
|
||||
<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,
|
||||
}} />
|
||||
|
||||
{/* 渐变光晕 - 左上 */}
|
||||
<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>
|
||||
|
||||
{/* 主要内容卡片 */}
|
||||
<Card className="bg-white rounded-2xl shadow-xl -mt-4">
|
||||
<View className="p-6 text-center">
|
||||
{/* 状态图标 */}
|
||||
{loading ? renderLoading() : confirmed ? renderSuccess() : error ? renderError() : renderInitial()}
|
||||
<Text style={{
|
||||
fontSize: '28px', fontWeight: '700',
|
||||
background: 'linear-gradient(90deg, #3b82f6, #6366f1, #8b5cf6)',
|
||||
WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text', marginBottom: '8px',
|
||||
}}>websopy</Text>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text className="text-xl font-bold text-gray-800 mb-2 block">
|
||||
{getTitle()}
|
||||
</Text>
|
||||
<View style={{
|
||||
width: '60px', height: '3px',
|
||||
background: 'linear-gradient(90deg, transparent, #3b82f6, transparent)',
|
||||
marginTop: '20px', borderRadius: '2px',
|
||||
}} />
|
||||
</View>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text className="text-gray-600 mb-6 block text-sm">
|
||||
{getDescription()}
|
||||
</Text>
|
||||
{/* 主按钮 - 渐变发光按钮 */}
|
||||
<View style={{
|
||||
width: '100%', maxWidth: '320px',
|
||||
background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 50%, #6366f1 100%)',
|
||||
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>
|
||||
|
||||
{/* 用户信息 */}
|
||||
{!loading && !confirmed && !error && 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 style={{ width: '100%', maxWidth: '320px', height: '44px', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: '16px' }} onClick={handleCancel}>
|
||||
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '14px' }}>取消</Text>
|
||||
</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 className="flex items-center justify-center mt-6">
|
||||
<View style={{ padding: '8px', marginRight: '4px' }} onClick={() => setAgreementChecked(!agreementChecked)}>
|
||||
<View style={{
|
||||
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',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{agreementChecked && <Text style={{ color: '#fff', fontSize: '12px' }}>✓</Text>}
|
||||
</View>
|
||||
</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="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>
|
||||
</Card>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
{/* 底部说明 */}
|
||||
<View className="text-center mt-6 pb-8">
|
||||
<Text className="text-xs text-gray-400">
|
||||
如有问题,请联系管理员
|
||||
</Text>
|
||||
{/* 底部装饰 */}
|
||||
<View style={{ position: 'absolute', bottom: '40px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<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>
|
||||
<View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#6366f1', boxShadow: '0 0 10px rgba(99, 102, 241, 0.8)' }} />
|
||||
</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="flex items-start">
|
||||
<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 {View, Text} from '@tarojs/components'
|
||||
import {Avatar, Button} from '@nutui/nutui-react-taro'
|
||||
import {User} from '@nutui/icons-react-taro'
|
||||
import {useThemeStyles} from '@/hooks/useTheme'
|
||||
import {useUser} from '@/hooks/useUser'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Avatar, Button, ConfigProvider, Grid, Empty } from '@nutui/nutui-react-taro'
|
||||
import { User, Location, Order, Refresh } from '@nutui/icons-react-taro'
|
||||
import { useThemeStyles } from '@/hooks/useTheme'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { listShopOrder } from '@/api/shop/shopOrder'
|
||||
import type { ShopOrder } from '@/api/shop/shopOrder/model'
|
||||
|
||||
const RiderIndex: React.FC = () => {
|
||||
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) {
|
||||
return (
|
||||
@@ -16,7 +60,7 @@ const RiderIndex: React.FC = () => {
|
||||
<View className="bg-white rounded-xl p-4">
|
||||
<Text className="text-gray-700">请先登录后再进入骑手中心</Text>
|
||||
<View className="mt-3">
|
||||
<Button type="primary" onClick={() => Taro.navigateTo({url: '/passport/login'})}>
|
||||
<Button type="primary" onClick={() => Taro.navigateTo({ url: '/passport/login' })}>
|
||||
去登录
|
||||
</Button>
|
||||
</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 (
|
||||
<View className="bg-gray-100 min-h-screen">
|
||||
{/* 头部信息 */}
|
||||
<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">
|
||||
<Avatar
|
||||
size="50"
|
||||
src={getAvatarUrl()}
|
||||
icon={<User />}
|
||||
className="mr-4"
|
||||
style={{border: '2px solid rgba(255, 255, 255, 0.3)'}}
|
||||
style={{ border: '2px solid rgba(255, 255, 255, 0.3)' }}
|
||||
/>
|
||||
<View className="flex-1 flex-col">
|
||||
<View>
|
||||
<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 className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||
骑手中心
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="p-4">
|
||||
<View className="bg-white rounded-xl p-4">
|
||||
<Text className="text-gray-800 font-semibold">常用功能</Text>
|
||||
<View className="mt-3">
|
||||
<Button type="primary" block onClick={() => Taro.navigateTo({url: '/rider/orders/index'})}>
|
||||
我的配送订单
|
||||
<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 className="mx-4 -mt-6 rounded-xl bg-white p-4 relative z-10">
|
||||
<View className="font-semibold text-gray-400 text-sm mb-3">今日概览</View>
|
||||
<View className="grid grid-cols-4 gap-2">
|
||||
{statCards.map((card) => (
|
||||
<View key={card.label} className={`${card.color} rounded-lg p-3 text-center`}>
|
||||
<View className={`text-2xl font-bold ${card.textColor}`}>{card.value}</View>
|
||||
<View className="text-xs text-gray-500 mt-1">{card.label}</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default RiderIndex
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ const StoreIndex: React.FC = () => {
|
||||
>
|
||||
<Grid.Item text="门店订单" onClick={() => navigateToPage('/store/orders/index')}>
|
||||
<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" />
|
||||
</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 ? (
|
||||
<>
|
||||
{/* 兑换说明 */}
|
||||
<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">
|
||||
<Gift size="20" className="text-blue-600 mr-2" />
|
||||
<Text className="font-semibold text-blue-800">兑换说明</Text>
|
||||
|
||||
@@ -264,7 +264,7 @@ const GiftCardUse = () => {
|
||||
</Text>
|
||||
|
||||
{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-700">{gift.contactInfo}</Text>
|
||||
</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