feat: implement Redux store with slices for user, order, file, and printer management

- Added Redux store configuration in `src/store/index.ts`.
- Created slices for user, order, file, and printer with respective reducers and actions.
- Implemented custom hooks `useAppDispatch` and `useAppSelector` for typed Redux usage.
- Defined types for file, order, printer, and user in `src/types`.
- Added utility functions for formatting prices, file sizes, and dates in `src/utils`.
- Created common styles and animations in `src/styles` for consistent UI design.
- Integrated API request handling with token management in `src/utils/request.ts`.
- Implemented local storage management for user token and info in `src/utils/storage.ts`.
This commit is contained in:
Mikuisnotavailable
2026-03-30 23:19:43 +08:00
parent 96cfc609b2
commit a424d06731
69 changed files with 5555 additions and 106 deletions

View File

@@ -1,7 +1,7 @@
{ module.exports = {
"extends": ["taro/react"], extends: ['taro/react'],
"rules": { rules: {
"react/jsx-uses-react": "off", 'react/jsx-uses-react': 'off',
"react/react-in-jsx-scope": "off" 'react/react-in-jsx-scope': 'off',
} },
} };

156
README.md Normal file
View File

@@ -0,0 +1,156 @@
# 萝卜打印 - 微信小程序
付费打印服务微信小程序前端项目,基于 Taro + React + TypeScript。
## 技术栈
| 技术 | 说明 |
|------|------|
| 框架 | Taro 4.x + React |
| 语言 | TypeScript |
| 状态管理 | Redux Toolkit |
| 编译工具 | Vite |
| UI | 自定义组件 |
## 项目结构
```
src/
├── api/ # API 接口层
│ ├── auth.ts # 认证服务
│ ├── file.ts # 文件服务
│ ├── order.ts # 订单服务
│ ├── printer.ts # 打印机服务
│ ├── price.ts # 价目服务
│ └── pay.ts # 支付服务
├── config/ # 配置文件
│ └── api.ts # API 地址配置
├── store/ # Redux 状态管理
│ ├── index.ts # Store 入口
│ ├── hooks.ts # Hooks
│ └── slices/ # Slice 模块
│ ├── userSlice.ts
│ ├── orderSlice.ts
│ ├── fileSlice.ts
│ └── printerSlice.ts
├── pages/ # 页面
│ ├── index/ # 首页
│ ├── login/ # 登录页
│ ├── user/ # 用户中心
│ ├── file/ # 文件管理
│ └── order/ # 订单管理
├── components/ # 公共组件
│ ├── OrderCard/ # 订单卡片
│ └── PriceTable/ # 价目表
├── types/ # TypeScript 类型
│ ├── user.ts
│ ├── order.ts
│ ├── file.ts
│ ├── printer.ts
│ └── price.ts
├── utils/ # 工具函数
│ ├── format.ts # 格式化工具
│ └── storage.ts # 本地存储
└── hooks/ # 自定义 Hooks
└── useAuth.ts # 认证 Hook
```
## 环境配置
API 地址配置:`src/config/api.ts`
```typescript
const DEV_API_URL = 'http://localhost:8080';
const PROD_API_URL = 'http://localhost:8080';
export const API_BASE_URL = process.env.NODE_ENV === 'development' ? DEV_API_URL : PROD_API_URL;
```
## 开发命令
```bash
# 安装依赖
npm install
# 开发模式(微信小程序)
npm run dev:weapp
# 生产构建
npm run build:weapp
```
## 微信开发者工具配置
1. 导入项目目录:`dist/miniprogram`
2. AppID使用 `touristappid` 或自己申请的
3. 启动开发服务器后点击刷新
## 主要功能
### 用户模块
- 手机号 + 验证码登录
- 微信一键登录
- 用户信息管理
- 退出登录
### 文件模块
- 文件上传
- 文件列表
- 文件删除
- 打印功能
### 订单模块
- 创建订单
- 订单列表
- 订单详情
- 取消订单
### 打印模块
- 选择打印规格A4/A3/照片)
- 选择打印颜色(黑白/彩色)
- 选择份数
- 选择打印机
## 页面说明
| 页面 | 路径 | 说明 |
|------|------|------|
| 首页 | `/pages/index/index` | 主入口,显示快捷操作和价目表 |
| 登录 | `/pages/login/index` | 手机号登录/微信登录 |
| 用户中心 | `/pages/user/index` | 用户信息、余额、菜单 |
| 我的文件 | `/pages/file/index` | 文件列表管理 |
| 上传文件 | `/pages/file/upload/index` | 文件上传 |
| 我的订单 | `/pages/order/index` | 订单列表 |
| 订单详情 | `/pages/order/detail/index` | 订单详情 |
## 代码规范
### 导入规范
- Redux 类型从 `@reduxjs/toolkit` 导入
- API_BASE_URL 从 `../config/api` 导入
### 函数缓存
- 使用 `useCallback` 缓存事件处理函数
- 使用 `useMemo` 缓存计算结果
### 命名规范
- 组件使用 PascalCase`OrderCard`
- 样式类使用小写 + 连字符(如 `.order-card`
- 常量使用全大写(如 `BASE_URL`
## 更新日志
### 2026-03-30
- 项目初始化
- 完成首页、登录页、用户中心、文件管理、订单管理页面开发
- 实现 Redux Toolkit 状态管理
- 实现 API 接口层统一管理
- 优化手机号输入验证
- 美化界面样式(淡雅配色)
### 技术优化
- 使用 `useCallback` 缓存函数减少重复创建
- 使用 `useMemo` 缓存计算结果
- API URL 统一配置文件管理
- Redux 导入路径修正为 `@reduxjs/toolkit`
- 提取共享 request 函数消除代码重复(`src/utils/request.ts`

110
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.7.7", "@babel/runtime": "^7.7.7",
"@reduxjs/toolkit": "^2.11.2",
"@taro-hooks/plugin-react": "2", "@taro-hooks/plugin-react": "2",
"@taro-hooks/shared": "2", "@taro-hooks/shared": "2",
"@tarojs/components": "4.1.11", "@tarojs/components": "4.1.11",
@@ -27,6 +28,8 @@
"@tarojs/taro": "4.1.11", "@tarojs/taro": "4.1.11",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-redux": "^9.2.0",
"redux": "^5.0.1",
"taro-hooks": "2" "taro-hooks": "2"
}, },
"devDependencies": { "devDependencies": {
@@ -3184,6 +3187,32 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rnx-kit/babel-preset-metro-react-native": { "node_modules/@rnx-kit/babel-preset-metro-react-native": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmmirror.com/@rnx-kit/babel-preset-metro-react-native/-/babel-preset-metro-react-native-1.1.8.tgz", "resolved": "https://registry.npmmirror.com/@rnx-kit/babel-preset-metro-react-native/-/babel-preset-metro-react-native-1.1.8.tgz",
@@ -3361,6 +3390,18 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stencil/core": { "node_modules/@stencil/core": {
"version": "2.22.3", "version": "2.22.3",
"resolved": "https://registry.npmmirror.com/@stencil/core/-/core-2.22.3.tgz", "resolved": "https://registry.npmmirror.com/@stencil/core/-/core-2.22.3.tgz",
@@ -5254,6 +5295,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@@ -9572,6 +9619,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.5", "version": "5.1.5",
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz", "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz",
@@ -12355,6 +12412,29 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -12586,6 +12666,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -12750,6 +12845,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
@@ -14662,6 +14763,15 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -36,47 +36,50 @@
"author": "", "author": "",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.7.7", "@babel/runtime": "^7.7.7",
"@reduxjs/toolkit": "^2.11.2",
"@taro-hooks/plugin-react": "2",
"@taro-hooks/shared": "2",
"@tarojs/components": "4.1.11", "@tarojs/components": "4.1.11",
"@tarojs/helper": "4.1.11", "@tarojs/helper": "4.1.11",
"@tarojs/plugin-platform-weapp": "4.1.11", "@tarojs/plugin-framework-react": "4.1.11",
"@tarojs/plugin-platform-alipay": "4.1.11", "@tarojs/plugin-platform-alipay": "4.1.11",
"@tarojs/plugin-platform-tt": "4.1.11", "@tarojs/plugin-platform-h5": "4.1.11",
"@tarojs/plugin-platform-swan": "4.1.11",
"@tarojs/plugin-platform-jd": "4.1.11", "@tarojs/plugin-platform-jd": "4.1.11",
"@tarojs/plugin-platform-qq": "4.1.11", "@tarojs/plugin-platform-qq": "4.1.11",
"@tarojs/plugin-platform-h5": "4.1.11", "@tarojs/plugin-platform-swan": "4.1.11",
"@tarojs/plugin-platform-tt": "4.1.11",
"@tarojs/plugin-platform-weapp": "4.1.11",
"@tarojs/react": "4.1.11",
"@tarojs/runtime": "4.1.11", "@tarojs/runtime": "4.1.11",
"@tarojs/shared": "4.1.11", "@tarojs/shared": "4.1.11",
"@tarojs/taro": "4.1.11", "@tarojs/taro": "4.1.11",
"@taro-hooks/shared": "2",
"@tarojs/plugin-framework-react": "4.1.11",
"@tarojs/react": "4.1.11",
"@taro-hooks/plugin-react": "2",
"react-dom": "^18.0.0",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0",
"react-redux": "^9.2.0",
"redux": "^5.0.1",
"taro-hooks": "2" "taro-hooks": "2"
}, },
"devDependencies": { "devDependencies": {
"babel-plugin-import": "^1.13.3", "@babel/core": "^7.8.0",
"@babel/plugin-proposal-class-properties": "7.14.5", "@babel/plugin-proposal-class-properties": "7.14.5",
"@babel/preset-react": "^7.24.1",
"@tarojs/cli": "4.1.11", "@tarojs/cli": "4.1.11",
"@tarojs/vite-runner": "4.1.11", "@tarojs/vite-runner": "4.1.11",
"babel-preset-taro": "4.1.11",
"eslint-config-taro": "4.1.11",
"eslint": "^8.12.0",
"stylelint": "^14.4.0",
"terser": "^5.16.8",
"vite": "^4.2.0",
"@babel/preset-react": "^7.24.1",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"@vitejs/plugin-react": "^4.1.0", "@vitejs/plugin-react": "^4.1.0",
"eslint-plugin-react": "^7.8.2", "babel-plugin-import": "^1.13.3",
"babel-preset-taro": "4.1.11",
"eslint": "^8.12.0",
"eslint-config-taro": "4.1.11",
"eslint-plugin-import": "^2.12.0", "eslint-plugin-import": "^2.12.0",
"eslint-plugin-react": "^7.8.2",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"react-refresh": "^0.11.0", "react-refresh": "^0.11.0",
"@typescript-eslint/parser": "^6.2.0", "stylelint": "^14.4.0",
"@typescript-eslint/eslint-plugin": "^6.2.0", "terser": "^5.16.8",
"typescript": "^5.1.0", "typescript": "^5.1.0",
"@babel/core": "^7.8.0" "vite": "^4.2.0"
} }
} }

34
src/api/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
import { request } from '../utils/request';
import type { UserInfo, LoginResponse, UpdateUserRequest } from '../types';
export const authApi = {
login: (code: string) => {
return request<LoginResponse>({
url: '/auth/login',
method: 'POST',
data: { code },
});
},
refreshToken: (refreshToken: string) => {
return request<{ accessToken: string }>({
url: '/auth/refresh',
method: 'POST',
data: { refreshToken },
});
},
getUserInfo: () => {
return request<UserInfo>({
url: '/auth/userinfo',
});
},
updateUser: (data: UpdateUserRequest) => {
return request<UserInfo>({
url: '/auth/update',
method: 'POST',
data,
});
},
};

60
src/api/file.ts Normal file
View File

@@ -0,0 +1,60 @@
import Taro from '@tarojs/taro';
import { store } from '../store';
import type { FileInfo, UploadFileResponse, FileListResponse } from '../types';
import { API_BASE_URL } from '../config/api';
import { request } from '../utils/request';
export const uploadFile = (filePath: string): Promise<UploadFileResponse> => {
return new Promise((resolve, reject) => {
const token = store.getState().user.token;
Taro.uploadFile({
url: `${API_BASE_URL}/file/upload`,
filePath,
name: 'file',
header: {
'Authorization': token ? `Bearer ${token}` : '',
},
success: (res) => {
if (res.statusCode === 200) {
resolve(JSON.parse(res.data));
} else {
reject(new Error('Upload failed'));
}
},
fail: reject,
} as any);
});
};
export const fileApi = {
upload: uploadFile,
getFile: (fileId: number) => {
return request<FileInfo>({
url: `/file/${fileId}`,
});
},
deleteFile: (fileId: number) => {
return request<void>({
url: `/file/${fileId}`,
method: 'DELETE',
});
},
getFileList: (page = 1, pageSize = 20) => {
return request<FileListResponse>({
url: '/file/list',
data: { page, pageSize },
});
},
addWatermark: (fileId: number, watermarkText: string) => {
return request<{ watermarkUrl: string }>({
url: `/file/${fileId}/watermark`,
method: 'POST',
data: { watermark: watermarkText },
});
},
};

6
src/api/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { authApi } from './auth';
export { fileApi } from './file';
export { orderApi } from './order';
export { printerApi } from './printer';
export { priceApi } from './price';
export { payApi } from './pay';

40
src/api/order.ts Normal file
View File

@@ -0,0 +1,40 @@
import { request } from '../utils/request';
import type { Order, CreateOrderRequest, OrderListResponse } from '../types';
export const orderApi = {
createOrder: (data: CreateOrderRequest) => {
return request<Order>({
url: '/order/create',
method: 'POST',
data,
});
},
getOrder: (orderId: number) => {
return request<Order>({
url: `/order/${orderId}`,
});
},
getOrderList: (page = 1, pageSize = 20) => {
return request<OrderListResponse>({
url: '/order/list',
data: { page, pageSize },
});
},
updateOrderStatus: (orderId: number, status: number) => {
return request<Order>({
url: `/order/${orderId}/status`,
method: 'PUT',
data: { status },
});
},
cancelOrder: (orderId: number) => {
return request<Order>({
url: `/order/${orderId}/cancel`,
method: 'PUT',
});
},
};

31
src/api/pay.ts Normal file
View File

@@ -0,0 +1,31 @@
import { request } from '../utils/request';
export const payApi = {
createWxPayOrder: (orderId: number) => {
return request<{
prepayId: string;
paySign: string;
timeStamp: string;
nonceStr: string;
signType: string;
}>({
url: '/pay/create',
method: 'POST',
data: { orderId },
});
},
queryPayStatus: (orderId: number) => {
return request<{ status: number }>({
url: `/pay/query/${orderId}`,
});
},
payByBalance: (orderId: number) => {
return request<{ success: boolean }>({
url: '/pay/balance',
method: 'POST',
data: { orderId },
});
},
};

26
src/api/price.ts Normal file
View File

@@ -0,0 +1,26 @@
import { request } from '../utils/request';
import type { PriceItem } from '../types';
export const priceApi = {
getPriceList: () => {
return request<PriceItem[]>({
url: '/price/list',
});
},
createPriceItem: (data: Omit<PriceItem, 'id'>) => {
return request<PriceItem>({
url: '/price/create',
method: 'POST',
data,
});
},
updatePriceItem: (id: number, data: Partial<PriceItem>) => {
return request<PriceItem>({
url: `/price/${id}`,
method: 'PUT',
data,
});
},
};

39
src/api/printer.ts Normal file
View File

@@ -0,0 +1,39 @@
import { request } from '../utils/request';
import type { Printer } from '../types';
export const printerApi = {
getPrinters: () => {
return request<Printer[]>({
url: '/print/printers',
});
},
getPrinterStatus: (printerId: number) => {
return request<Printer>({
url: `/print/printer/${printerId}/status`,
});
},
registerPrinter: (name: string, location: string) => {
return request<Printer>({
url: '/print/printer/register',
method: 'POST',
data: { name, location },
});
},
submitPrintJob: (orderId: number, printerId: number) => {
return request<{ jobId: number }>({
url: '/print/job/submit',
method: 'POST',
data: { orderId, printerId },
});
},
cancelPrintJob: (jobId: number) => {
return request<void>({
url: `/print/job/${jobId}/cancel`,
method: 'PUT',
});
},
};

View File

@@ -1,9 +1,19 @@
export default { export default {
pages: ["pages/index/index"], pages: [
"pages/index/index",
"pages/login/index",
"pages/user/index",
"pages/file/index",
"pages/file/upload/index",
"pages/order/index",
"pages/order/detail/index",
"pages/print/index",
"pages/print/confirm/index",
],
window: { window: {
backgroundTextStyle: "light", backgroundTextStyle: "light",
navigationBarBackgroundColor: "#fff", navigationBarBackgroundColor: "#1890ff",
navigationBarTitleText: "WeChat", navigationBarTitleText: "萝卜打印",
navigationBarTextStyle: "black", navigationBarTextStyle: "white",
}, },
}; };

View File

@@ -0,0 +1,165 @@
/* ============================================
全局样式入口 - 爱印通
============================================ */
/* 引入设计系统和动画 */
@import './styles/variables.css';
@import './styles/common.css';
@import './styles/animations.css';
/* 全局页面样式 */
page {
background: var(--bg-page);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
font-size: var(--font-size-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-placeholder);
}
/* 选中文本样式 */
::selection {
background: rgba(90, 147, 223, 0.2);
color: var(--text-primary);
}
/* 焦点样式 */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* 链接样式 */
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--primary-dark);
}
/* 图片样式 */
image {
max-width: 100%;
height: auto;
}
/* 文本选择禁用 */
.no-select {
user-select: none;
-webkit-user-select: none;
}
/* 安全区域 */
.safe-area-top {
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-left {
padding-left: constant(safe-area-inset-left);
padding-left: env(safe-area-inset-left);
}
.safe-area-right {
padding-right: constant(safe-area-inset-right);
padding-right: env(safe-area-inset-right);
}
/* 隐藏滚动条但保持可滚动 */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* 文本截断 */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.truncate-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* 点击高亮移除 */
* {
-webkit-tap-highlight-color: transparent;
}
/* 打印样式 */
@media print {
page {
background: white;
}
.no-print {
display: none !important;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
/* 未来可扩展深色模式 */
}
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,5 +1,4 @@
import { Component } from 'react' import { Component } from 'react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import './app.css' import './app.css'
@@ -12,11 +11,9 @@ class App extends Component<PropsWithChildren> {
componentDidHide() { } componentDidHide() { }
// this.props.children 是将要会渲染的页面
render() { render() {
return this.props.children return this.props.children
} }
} }
export default App export default App

View File

@@ -0,0 +1,164 @@
/* ============================================
订单卡片组件样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
.order-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
transition: all var(--transition-base);
}
.order-card:active {
transform: scale(0.98);
box-shadow: var(--shadow-sm);
}
/* 订单头部 */
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-light);
}
.order-id {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.order-status {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
padding: 4px 12px;
border-radius: 12px;
background: rgba(24, 144, 255, 0.1);
color: var(--info-color);
}
.order-status.completed {
background: rgba(82, 196, 26, 0.1);
color: var(--success-color);
}
.order-status.cancelled {
background: rgba(255, 77, 79, 0.1);
color: var(--error-color);
}
.order-status.pending {
background: rgba(250, 173, 20, 0.1);
color: var(--warning-color);
}
/* 订单内容 */
.order-body {
margin-bottom: 16px;
}
.file-name {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
display: block;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-detail {
font-size: var(--font-size-sm);
color: var(--text-secondary);
display: block;
margin-bottom: 4px;
}
.printer-name {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
display: block;
}
/* 订单底部 */
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border-light);
padding-top: 16px;
}
.order-price {
font-size: var(--font-size-xl);
color: var(--error-color);
font-weight: var(--font-weight-bold);
}
.order-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 订单操作区 */
.order-actions {
display: flex;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-light);
}
.order-action-btn {
flex: 1;
height: 60px;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
border: none;
transition: all var(--transition-fast);
}
.order-action-btn::after {
border: none;
}
.order-action-btn:active {
transform: scale(0.96);
}
.order-action-btn.primary {
background: var(--primary-gradient);
color: #fff;
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.2);
}
.order-action-btn.secondary {
background: #f5f5f7;
color: var(--text-secondary);
}
/* 卡片变体 */
.order-card.highlighted {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(90, 147, 223, 0.1), var(--shadow-md);
}
.order-card.compact {
padding: 16px;
}
.order-card.compact .order-header {
margin-bottom: 12px;
padding-bottom: 12px;
}
.order-card.compact .order-body {
margin-bottom: 12px;
}

View File

@@ -0,0 +1 @@
export { OrderCard } from './index.tsx';

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import { OrderStatusMap } from '../../types';
import { formatPrice, formatDate } from '../../utils/format';
import './OrderCard.css';
interface OrderCardProps {
order: {
id: number;
fileName: string;
printerName: string;
printType: string;
colorType: string;
copies: number;
totalPrice: number;
orderStatus: number;
payStatus: number;
createdAt: string;
};
onClick?: () => void;
}
export const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
return (
<View className='order-card' onClick={onClick}>
<View className='order-header'>
<Text className='order-id'>: {order.id}</Text>
<Text className='order-status'>{OrderStatusMap[order.orderStatus as keyof typeof OrderStatusMap]}</Text>
</View>
<View className='order-body'>
<Text className='file-name'>{order.fileName}</Text>
<Text className='order-detail'>
{order.printType} {order.colorType} x {order.copies}
</Text>
<Text className='printer-name'>: {order.printerName}</Text>
</View>
<View className='order-footer'>
<Text className='order-price'>{formatPrice(order.totalPrice)}</Text>
<Text className='order-time'>{formatDate(order.createdAt)}</Text>
</View>
</View>
);
};

View File

@@ -0,0 +1,140 @@
/* ============================================
价目表组件样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
.price-table {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
.price-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
display: block;
margin-bottom: 20px;
padding-left: 12px;
border-left: 4px solid var(--primary-color);
}
.price-list {
display: flex;
flex-direction: column;
}
.price-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 12px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
cursor: pointer;
}
.price-item:nth-child(odd) {
background: #fafbfc;
}
.price-item:nth-child(even) {
background: var(--bg-card);
}
.price-item:active {
transform: scale(0.98);
}
.price-item.selected {
background: rgba(90, 147, 223, 0.1);
border: 1px solid var(--primary-color);
}
.price-item.selected .price-name {
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
}
.price-name {
font-size: var(--font-size-base);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
transition: color var(--transition-fast);
}
.price-value {
font-size: var(--font-size-md);
color: var(--error-color);
font-weight: var(--font-weight-semibold);
}
/* 价目表变体 */
.price-table.compact {
padding: 16px;
}
.price-table.compact .price-title {
font-size: var(--font-size-lg);
margin-bottom: 16px;
}
.price-table.compact .price-item {
padding: 12px 8px;
}
.price-table.compact .price-name {
font-size: var(--font-size-sm);
}
.price-table.compact .price-value {
font-size: var(--font-size-base);
}
/* 带图标的价目项 */
.price-item-with-icon {
padding-left: 16px;
}
.price-item-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
font-size: 20px;
background: linear-gradient(135deg, #e8f0fe 0%, #d0dcfa 100%);
}
.price-item-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
/* 促销标签 */
.price-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
color: #fff;
font-size: 18px;
border-radius: 8px;
margin-left: 8px;
transform: scale(0.8);
}
/* 原价显示 */
.price-original {
font-size: var(--font-size-xs);
color: var(--text-placeholder);
text-decoration: line-through;
margin-right: 4px;
}

View File

@@ -0,0 +1 @@
export { PriceTable } from './index';

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import { DefaultPriceList, PriceItem } from '../../types';
import { formatPrice } from '../../utils/format';
import './PriceTable.css';
interface PriceTableProps {
prices?: PriceItem[];
selectedType?: string;
onSelect?: (item: PriceItem) => void;
}
export const PriceTable: React.FC<PriceTableProps> = ({
prices = DefaultPriceList,
selectedType,
onSelect
}) => {
return (
<View className='price-table'>
<Text className='price-title'></Text>
<View className='price-list'>
{prices.map(item => (
<View
key={item.id}
className={`price-item ${selectedType === item.name ? 'selected' : ''}`}
onClick={() => onSelect?.(item)}
>
<Text className='price-name'>{item.name}</Text>
<Text className='price-value'>{formatPrice(item.price)}/{item.unit}</Text>
</View>
))}
</View>
</View>
);
};

2
src/components/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { OrderCard } from './OrderCard';
export { PriceTable } from './PriceTable';

4
src/config/api.ts Normal file
View File

@@ -0,0 +1,4 @@
const DEV_API_URL = 'http://localhost:8080';
const PROD_API_URL = 'http://localhost:8080';
export const API_BASE_URL = process.env.NODE_ENV === 'development' ? DEV_API_URL : PROD_API_URL;

1
src/hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './useAuth';

70
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,70 @@
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { setUser, logout as logoutAction, updateUserInfo, setLoading } from '../store/slices/userSlice';
import { authApi } from '../api/auth';
import { storage } from '../utils/storage';
export const useAuth = () => {
const dispatch = useAppDispatch();
const { info, token, refreshToken, isLoggedIn, loading } = useAppSelector(state => state.user);
const login = useCallback(async (code: string) => {
dispatch(setLoading(true));
try {
const res = await authApi.login(code);
dispatch(setUser({
user: res.userInfo,
token: res.accessToken,
refreshToken: res.refreshToken,
}));
storage.setToken(res.accessToken);
storage.setUserInfo(res.userInfo);
return res;
} catch (error) {
console.error('Login failed:', error);
throw error;
} finally {
dispatch(setLoading(false));
}
}, [dispatch]);
const logout = useCallback(() => {
dispatch(logoutAction());
storage.removeToken();
storage.removeUserInfo();
}, [dispatch]);
const refreshAccessToken = useCallback(async () => {
if (!refreshToken) return;
try {
const res = await authApi.refreshToken(refreshToken);
dispatch(setUser({ user: info!, token: res.accessToken, refreshToken }));
storage.setToken(res.accessToken);
} catch (error) {
logout();
throw error;
}
}, [dispatch, info, logout, refreshToken]);
const updateInfo = useCallback(async (data: { nickname?: string; avatar?: string }) => {
try {
const res = await authApi.updateUser(data);
dispatch(updateUserInfo(res));
storage.setUserInfo(res);
} catch (error) {
console.error('Update user info failed:', error);
throw error;
}
}, [dispatch]);
return {
user: info,
token,
isLoggedIn,
loading,
login,
logout,
refreshToken: refreshAccessToken,
updateInfo,
};
};

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '我的文件',
};

277
src/pages/file/index.css Normal file
View File

@@ -0,0 +1,277 @@
/* ============================================
文件列表页面样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
page {
background: linear-gradient(180deg, #e8f0fe 0%, #f0f5ff 30%, #fafbfc 100%);
min-height: 100vh;
}
.file-page {
min-height: 100vh;
padding: 32px 24px 140px;
}
/* 页面头部 */
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
animation: fadeInDown 0.4s ease forwards;
}
.header-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.upload-btn {
background: var(--primary-gradient);
color: #fff;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
padding: 14px 24px;
border-radius: 36px;
border: none;
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.2);
transition: all var(--transition-base);
white-space: nowrap;
}
.upload-btn::after {
border: none;
}
.upload-btn:active {
transform: scale(0.96);
}
/* 文件列表 */
.file-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.file-item {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
align-items: center;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
transition: all var(--transition-base);
animation: slideUp 0.4s ease forwards;
opacity: 0;
}
.file-item:nth-child(1) { animation-delay: 0.1s; }
.file-item:nth-child(2) { animation-delay: 0.2s; }
.file-item:nth-child(3) { animation-delay: 0.3s; }
.file-item:active {
transform: scale(0.98);
box-shadow: var(--shadow-sm);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-icon {
font-size: 44px;
margin-right: 16px;
flex-shrink: 0;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8f0fe 0%, #d0dcfa 100%);
border-radius: var(--radius-sm);
}
.file-info {
flex: 1;
overflow: hidden;
}
.file-name {
font-size: var(--font-size-base);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.file-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 操作按钮 */
.file-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.action-btn {
font-size: var(--font-size-xs);
padding: 8px 14px;
border-radius: var(--radius-sm);
border: none;
transition: all var(--transition-fast);
white-space: nowrap;
}
.action-btn::after {
border: none;
}
.action-btn:active {
transform: scale(0.95);
}
.action-btn.print {
color: var(--primary-color);
background: rgba(90, 147, 223, 0.1);
}
.action-btn.print:active {
background: rgba(90, 147, 223, 0.2);
}
.action-btn.delete {
color: var(--error-color);
background: rgba(255, 77, 79, 0.1);
}
.action-btn.delete:active {
background: rgba(255, 77, 79, 0.2);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
animation: fadeIn 0.5s ease forwards;
}
.empty-icon {
font-size: 100px;
margin-bottom: 20px;
opacity: 0.4;
}
.empty-text {
font-size: var(--font-size-lg);
color: var(--text-tertiary);
font-weight: var(--font-weight-medium);
margin-bottom: 8px;
}
.empty-hint {
font-size: var(--font-size-sm);
color: var(--text-placeholder);
}
/* 筛选栏 */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 4px;
}
.filter-chip {
padding: 8px 20px;
border-radius: 24px;
font-size: var(--font-size-xs);
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border-primary);
white-space: nowrap;
transition: all var(--transition-fast);
}
.filter-chip.active {
background: var(--primary-gradient);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.2);
}
.filter-chip:active {
transform: scale(0.95);
}
/* 统计信息 */
.stats-bar {
display: flex;
justify-content: space-between;
padding: 16px 20px;
background: var(--bg-card);
border-radius: var(--radius-md);
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--primary-color);
display: block;
}
.stat-label {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-top: 4px;
display: block;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

82
src/pages/file/index.tsx Normal file
View File

@@ -0,0 +1,82 @@
import React, { useState } from "react";
import { View, Text, Button } from "@tarojs/components";
import Taro from '@tarojs/taro';
import './index.css';
interface FileItem {
id: number;
name: string;
size: string;
time: string;
type: string;
}
const FileIndex = () => {
const [files] = useState<FileItem[]>([
{ id: 1, name: '期末考试复习资料.docx', size: '2.5 MB', time: '2026-03-30 10:30', type: 'word' },
{ id: 2, name: '毕业设计.pdf', size: '15.8 MB', time: '2026-03-29 15:20', type: 'pdf' },
{ id: 3, name: '证件照.jpg', size: '1.2 MB', time: '2026-03-28 09:15', type: 'image' },
]);
const handlePrint = (file: FileItem) => {
Taro.navigateTo({
url: `/pages/print/index?fileId=${file.id}&fileName=${file.name}`
});
};
const handleDelete = (fileId: number) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除这个文件吗?',
success: (res) => {
if (res.confirm) {
Taro.showToast({ title: '删除成功', icon: 'success' });
}
}
});
};
const getFileIcon = (type: string) => {
switch (type) {
case 'pdf': return '📕';
case 'word': return '📄';
case 'image': return '🖼️';
default: return '📁';
}
};
return (
<View className='file-page'>
<View className='file-header'>
<Text className='header-title'></Text>
<Button className="upload-btn"></Button>
</View>
{files.length > 0 ? (
<View className='file-list'>
{files.map(file => (
<View key={file.id} className='file-item'>
<View className='file-icon'>{getFileIcon(file.type)}</View>
<View className='file-info'>
<Text className='file-name'>{file.name}</Text>
<Text className='file-meta'>{file.size} · {file.time}</Text>
</View>
<View className='file-actions'>
<Text className='action-btn print' onClick={() => handlePrint(file)}></Text>
<Text className='action-btn delete' onClick={() => handleDelete(file.id)}></Text>
</View>
</View>
))}
</View>
) : (
<View className='empty-state'>
<Text className='empty-icon'>📂</Text>
<Text className='empty-text'></Text>
<Text className='empty-hint'></Text>
</View>
)}
</View>
);
};
export default FileIndex;

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '上传文件',
};

View File

@@ -0,0 +1,375 @@
/* ============================================
文件上传页面样式 - 萝卜打印
============================================ */
@import '../../../styles/variables.css';
page {
background: linear-gradient(180deg, #e8f0fe 0%, #f0f5ff 30%, #fafbfc 100%);
min-height: 100vh;
}
.upload-page {
min-height: 100vh;
padding: 32px 24px 140px;
}
/* 页面头部 */
.upload-header {
margin-bottom: 32px;
animation: fadeInDown 0.4s ease forwards;
}
.upload-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
display: block;
}
.upload-subtitle {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-top: 8px;
display: block;
}
/* 上传区域 */
.upload-area {
background: var(--bg-card);
border-radius: var(--radius-xl);
padding: 60px 32px;
box-shadow: var(--shadow-md);
min-height: 280px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--border-primary);
transition: all var(--transition-base);
position: relative;
overflow: hidden;
}
.upload-area.dragging {
border-color: var(--primary-color);
background: rgba(90, 147, 223, 0.05);
box-shadow: 0 0 0 4px rgba(90, 147, 223, 0.1);
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
animation: pulse 2s ease infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
.upload-icon {
font-size: 100px;
margin-bottom: 20px;
opacity: 0.6;
}
.upload-text {
font-size: var(--font-size-lg);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
margin-bottom: 8px;
}
.upload-hint {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 文件预览 */
.file-preview {
width: 100%;
display: flex;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #f8f9fb 0%, #f0f2f7 100%);
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
animation: slideIn 0.3s ease forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.file-icon {
font-size: 48px;
margin-right: 20px;
flex-shrink: 0;
}
.file-info {
flex: 1;
overflow: hidden;
}
.file-name {
font-size: var(--font-size-base);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.file-size {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.change-file {
font-size: var(--font-size-sm);
color: var(--primary-color);
padding: 8px 16px;
background: rgba(90, 147, 223, 0.1);
border-radius: var(--radius-sm);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.change-file:active {
background: rgba(90, 147, 223, 0.2);
}
/* 操作按钮 */
.file-actions {
margin-top: 32px;
}
.upload-btn {
width: 100%;
height: 96px;
background: var(--primary-gradient);
color: #fff;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
border-radius: 48px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(90, 147, 223, 0.3);
transition: all var(--transition-base);
letter-spacing: 2px;
}
.upload-btn::after {
border: none;
}
.upload-btn:active {
transform: scale(0.97);
}
.upload-btn[disabled] {
background: linear-gradient(135deg, #c5cede 0%, #b0bccf 100%);
box-shadow: none;
}
/* 进度条 */
.progress-area {
margin-top: 24px;
animation: fadeIn 0.3s ease forwards;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-text {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.progress-percent {
font-size: var(--font-size-xs);
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
}
.progress-bar {
height: 8px;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-gradient);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* 提示区域 */
.tips-section {
background: linear-gradient(135deg, #fefcf3 0%, #fdf9f0 100%);
border-radius: var(--radius-lg);
padding: 24px;
margin-top: 32px;
border: 1px solid #f5e8c7;
box-shadow: var(--shadow-sm);
}
.tips-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: #b08945;
display: block;
margin-bottom: 16px;
}
.tips-item {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.tips-item:last-child {
margin-bottom: 0;
}
.tips-dot {
color: #d4a853;
margin-right: 10px;
font-size: var(--font-size-xs);
line-height: 1.5;
}
.tips-text {
font-size: var(--font-size-sm);
color: #8a7340;
flex: 1;
line-height: 1.6;
}
/* 打印选项 */
.print-options {
margin-top: 32px;
}
.option-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
margin-bottom: 16px;
}
.option-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: 16px;
display: block;
}
.option-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.option-btn {
flex: 1;
min-width: 100px;
height: 72px;
background: #f8f9fb;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.option-btn:active {
transform: scale(0.96);
}
.option-btn.selected {
background: rgba(90, 147, 223, 0.1);
border-color: var(--primary-color);
color: var(--primary-color);
}
.option-label {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-top: 4px;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,138 @@
import React, { useState } from "react";
import { View, Text, Button, Image } from "@tarojs/components";
import Taro from '@tarojs/taro';
import './index.css';
const FileUpload = () => {
const [selectedFile, setSelectedFile] = useState<{ path: string; name: string; size: number } | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const handleSelectFile = () => {
Taro.chooseMessageFile({
count: 1,
type: 'file',
success: (res) => {
const file = res.tempFiles[0];
setSelectedFile({
path: file.path,
name: file.name,
size: file.size,
});
},
fail: () => {
Taro.showToast({ title: '选择文件失败', icon: 'none' });
}
});
};
const handleUpload = async () => {
if (!selectedFile) {
Taro.showToast({ title: '请先选择文件', icon: 'none' });
return;
}
setUploading(true);
setUploadProgress(0);
const interval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 90) {
clearInterval(interval);
return prev;
}
return prev + 10;
});
}, 200);
try {
await new Promise(resolve => setTimeout(resolve, 2000));
clearInterval(interval);
setUploadProgress(100);
Taro.showToast({ title: '上传成功', icon: 'success' });
setTimeout(() => {
Taro.navigateBack();
}, 1500);
} catch (error) {
clearInterval(interval);
Taro.showToast({ title: '上传失败', icon: 'none' });
} finally {
setUploading(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
return (
<View className='upload-page'>
<View className='upload-header'>
<Text className='upload-title'></Text>
<Text className='upload-subtitle'> WordPDF</Text>
</View>
<View className='upload-area' onClick={handleSelectFile}>
{selectedFile ? (
<View className='file-preview'>
<View className='file-icon'>📄</View>
<View className='file-info'>
<Text className='file-name'>{selectedFile.name}</Text>
<Text className='file-size'>{formatFileSize(selectedFile.size)}</Text>
</View>
<Text className='change-file' onClick={(e) => { e.stopPropagation(); handleSelectFile(); }}></Text>
</View>
) : (
<View className='upload-placeholder'>
<Text className='upload-icon'>📤</Text>
<Text className='upload-text'></Text>
<Text className='upload-hint'> 50MB</Text>
</View>
)}
</View>
{selectedFile && (
<View className='file-actions'>
<Button
className='upload-btn'
onClick={handleUpload}
loading={uploading}
disabled={uploading}
>
{uploading ? `上传中 ${uploadProgress}%` : '开始上传'}
</Button>
</View>
)}
{uploading && (
<View className='progress-area'>
<View className='progress-bar'>
<View className='progress-fill' style={{ width: `${uploadProgress}%` }} />
</View>
</View>
)}
<View className='tips-section'>
<Text className='tips-title'></Text>
<View className='tips-item'>
<Text className='tips-dot'></Text>
<Text className='tips-text'> 24 </Text>
</View>
<View className='tips-item'>
<Text className='tips-dot'></Text>
<Text className='tips-text'></Text>
</View>
<View className='tips-item'>
<Text className='tips-dot'></Text>
<Text className='tips-text'></Text>
</View>
</View>
</View>
);
};
export default FileUpload;

View File

@@ -1,4 +1,4 @@
export default { export default {
navigationBarTitleText: 'Taro-hooks', navigationBarTitleText: '萝卜打印',
enableShareAppMessage: true, navigationStyle: 'custom',
}; };

View File

@@ -1,56 +1,405 @@
/* ============================================
首页样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
page { page {
background-color: white; background: linear-gradient(180deg, #e8f0fe 0%, #f0f5ff 30%, #fafbfc 100%);
padding : 14px; min-height: 100vh;
box-sizing : border-box;
color : #333;
} }
.wrapper { .index-page {
display : flex; min-height: 100vh;
padding-bottom: 140px;
}
/* 头部区域 */
.header {
background: var(--primary-gradient);
padding: 100px 32px 80px;
position: relative;
overflow: hidden;
border-radius: 0 0 40px 40px;
}
.header::before {
content: '';
position: absolute;
top: -30%;
right: -15%;
width: 350px;
height: 350px;
background: rgba(255, 255, 255, 0.12);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.header::after {
content: '';
position: absolute;
bottom: -20%;
left: -10%;
width: 250px;
height: 250px;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
animation: float 8s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-20px) scale(1.05);
}
}
.logo-area {
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
.app-title {
font-size: 48px;
font-weight: 700;
color: #fff;
letter-spacing: 4px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.app-subtitle {
font-size: 24px;
color: rgba(255, 255, 255, 0.85);
margin-top: 8px;
letter-spacing: 2px;
}
/* 登录按钮区域 */
.login-section {
padding: 0 32px;
margin-top: -30px;
position: relative;
z-index: 2;
animation: slideUp 0.5s ease forwards;
}
.login-btn {
width: 100%;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(90, 147, 223, 0.2);
color: var(--primary-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
padding: 24px 0;
border-radius: 50px;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
transition: all var(--transition-base);
letter-spacing: 2px;
}
.login-btn::after {
border: none;
}
.login-btn:active {
transform: scale(0.97);
}
/* 用户卡片 */
.user-section {
padding: 0 32px;
margin-top: -30px;
position: relative;
z-index: 2;
animation: slideUp 0.5s ease forwards;
}
.user-card {
background: rgba(255, 255, 255, 0.95);
border-radius: var(--radius-xl);
padding: 24px;
display: flex;
align-items: center;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
}
.avatar-wrapper {
margin-right: 20px;
position: relative;
}
.avatar-img {
width: 80px;
height: 80px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.avatar-placeholder {
width: 80px;
height: 80px;
background: var(--primary-gradient);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid #fff;
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.3);
}
.avatar-text {
font-size: 36px;
color: #fff;
font-weight: var(--font-weight-bold);
}
.user-detail {
flex: 1;
}
.user-name {
font-size: var(--font-size-xl);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
display: block;
margin-bottom: 4px;
}
.user-balance {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.logout-btn-small {
background: #f5f5f7;
color: var(--text-tertiary);
font-size: var(--font-size-xs);
padding: 10px 20px;
border-radius: 24px;
border: none;
transition: all var(--transition-base);
}
.logout-btn-small:active {
background: #e8e8ea;
}
/* 快捷操作 */
.quick-actions {
display: flex;
padding: 32px 24px 0;
gap: 16px;
}
.action-item {
flex: 1;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 28px 12px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: var(--shadow-md);
transition: all var(--transition-base);
border: 1px solid var(--border-light);
position: relative;
overflow: hidden;
}
.action-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--primary-gradient);
opacity: 0;
transition: opacity var(--transition-base);
}
.action-item:active {
transform: scale(0.96);
box-shadow: var(--shadow-sm);
}
.action-item:active::before {
opacity: 1;
}
.action-item.primary {
background: var(--primary-gradient);
box-shadow: 0 8px 24px rgba(90, 147, 223, 0.3);
}
.action-item.primary .action-text {
color: #fff;
}
.action-item.primary .action-icon {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
.action-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #f0f5ff 0%, #e8eefa 100%);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 12px;
transition: all var(--transition-base);
}
.action-text {
font-size: var(--font-size-base);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
}
/* 价目表 */
.price-section {
padding: 28px 24px 0;
}
.price-table {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
.price-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
display: block;
margin-bottom: 20px;
padding-left: 12px;
border-left: 4px solid var(--primary-color);
}
.price-list {
display: flex;
flex-direction: column; flex-direction: column;
} }
.logo { .price-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 12px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.price-item:nth-child(odd) {
background: #fafbfc;
}
.price-item:nth-child(even) {
background: var(--bg-card);
}
.price-name {
font-size: var(--font-size-base);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
}
.price-value {
font-size: var(--font-size-md);
color: var(--error-color);
font-weight: var(--font-weight-semibold);
}
/* 使用提示 */
.tips-section {
padding: 28px 24px;
margin-top: 12px;
}
.tips-card {
background: linear-gradient(135deg, #fefcf3 0%, #fdf9f0 100%);
border-radius: var(--radius-lg);
padding: 24px;
border: 1px solid #f5e8c7;
box-shadow: var(--shadow-sm);
}
.tips-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: #b08945;
display: block; display: block;
width : 270px; margin-bottom: 16px;
height : 270px;
margin : 0 auto;
} }
.title, .tips-item {
.desc { display: flex;
text-align: center; align-items: center;
font-size : 32px; margin-bottom: 12px;
} }
.desc { .tips-item:last-child {
margin : 20px 0; margin-bottom: 0;
font-size: 28px;
color : rgba(0, 0, 0, .85);
} }
.list { .tips-number {
padding : 24px 0; width: 28px;
font-size : 32px; height: 28px;
display : flex; background: linear-gradient(135deg, #d4a853 0%, #c49a47 100%);
align-items : center; border-radius: 50%;
border-color: rgba(51, 51, 51, .1); display: flex;
border-style: solid; align-items: center;
border-width: 1px 0 1px 0; justify-content: center;
font-size: 18px;
color: #fff;
font-weight: var(--font-weight-medium);
margin-right: 12px;
flex-shrink: 0;
} }
.list:not(:first-child) { .tips-text {
border-top-width: 0; font-size: var(--font-size-sm);
color: #8a7340;
line-height: 1.5;
} }
.label { /* 动画 */
flex: 0.4; @keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.button { /* 装饰光晕 */
display : block; .index-page::before {
width : 100%; content: '';
background-color: transparent; position: fixed;
border-radius : 2px; top: -100px;
margin-top : 20px; right: -100px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(125, 179, 245, 0.1) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
} }

View File

@@ -1,45 +1,170 @@
import React, { useCallback } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { View, Text, Button, Image } from "@tarojs/components"; import { View, Text, Button, Image } from "@tarojs/components";
import { useEnv, useNavigationBar, useModal, useToast } from "taro-hooks"; import Taro from '@tarojs/taro';
import logo from "./hook.png"; import './index.css';
import './index.css' interface UserInfo {
nickname: string;
avatar: string;
balance: number;
}
const Index = () => { const Index = () => {
const env = useEnv(); const [isLoggedIn, setIsLoggedIn] = useState(false);
const { setTitle } = useNavigationBar({ title: "Taro Hooks" }); const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const showModal = useModal({
title: "Taro Hooks Canary!",
showCancel: false,
confirmColor: "#8c2de9",
confirmText: "支持一下"
});
const { show } = useToast({ mask: true });
const handleModal = useCallback(() => { useEffect(() => {
showModal({ content: "不如给一个star⭐!" }).then(() => { checkLoginStatus();
show({ title: "点击了支持!" }); }, []);
const checkLoginStatus = useCallback(() => {
const storedUser = Taro.getStorageSync('userInfo');
if (storedUser) {
setIsLoggedIn(true);
setUserInfo(storedUser);
} else {
setIsLoggedIn(false);
setUserInfo(null);
}
}, []);
const handleLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' });
}, []);
const handleUpload = useCallback(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/pages/login/index' });
return;
}
Taro.navigateTo({ url: '/pages/file/upload/index' });
}, [isLoggedIn]);
const handleMyFiles = useCallback(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/pages/login/index' });
return;
}
Taro.navigateTo({ url: '/pages/file/index' });
}, [isLoggedIn]);
const handleMyOrders = useCallback(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/pages/login/index' });
return;
}
Taro.navigateTo({ url: '/pages/order/index' });
}, [isLoggedIn]);
const handleLogout = useCallback(() => {
Taro.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
Taro.removeStorageSync('userInfo');
Taro.removeStorageSync('token');
setIsLoggedIn(false);
setUserInfo(null);
Taro.showToast({ title: '已退出登录', icon: 'success' });
}
}
}); });
}, [show, showModal]); }, []);
const priceList = useMemo(() => [
{ name: 'A4黑白打印', price: '¥0.10/页' },
{ name: 'A4彩色打印', price: '¥0.30/页' },
{ name: 'A3黑白打印', price: '¥0.20/页' },
{ name: 'A3彩色打印', price: '¥0.60/页' },
{ name: '照片打印', price: '¥2.00/张' },
], []);
const tipsList = useMemo(() => [
'支持 Word、PDF、图片等格式',
'请选择合适的打印规格',
'打印完成后请及时取走文件',
], []);
const userInitials = useMemo(() => {
if (!userInfo?.nickname) return 'U';
return userInfo.nickname[0]?.toUpperCase() || 'U';
}, [userInfo?.nickname]);
return ( return (
<View className="wrapper"> <View className='index-page'>
<Image className="logo" src={logo} /> <View className='header'>
<Text className="title">Taro而设计的Hooks Library</Text> <View className='logo-area'>
<Text className="desc"> <Text className='app-title'></Text>
70%API. API在H5端短板. 40+Hooks! <Text className='app-subtitle'></Text>
ahook适配Taro! 更多信息可以查看新版文档: https://next-version-taro-hooks.vercel.app/ </View>
</Text> </View>
<View className="list">
<Text className="label"></Text> {isLoggedIn && userInfo ? (
<Text className="note">{env}</Text> <View className='user-section'>
<View className='user-card'>
<View className='avatar-wrapper'>
{userInfo.avatar ? (
<Image className='avatar-img' src={userInfo.avatar} mode='aspectFill' />
) : (
<View className='avatar-placeholder'>
<Text className='avatar-text'>{userInitials}</Text>
</View>
)}
</View>
<View className='user-detail'>
<Text className='user-name'>{userInfo.nickname}</Text>
<Text className='user-balance'>: ¥{userInfo.balance.toFixed(2)}</Text>
</View>
<Button className='logout-btn-small' onClick={handleLogout}>退</Button>
</View>
</View>
) : (
<View className='login-section'>
<Button className='login-btn' onClick={handleLogin}> / </Button>
</View>
)}
<View className='quick-actions'>
<View className='action-item primary' onClick={handleUpload}>
<Text className='action-icon'>📤</Text>
<Text className='action-text'></Text>
</View>
<View className='action-item' onClick={handleMyFiles}>
<Text className='action-icon'>📁</Text>
<Text className='action-text'></Text>
</View>
<View className='action-item' onClick={handleMyOrders}>
<Text className='action-icon'>📋</Text>
<Text className='action-text'></Text>
</View>
</View>
<View className='price-section'>
<View className='price-table'>
<Text className='price-title'></Text>
<View className='price-list'>
{priceList.map((item, index) => (
<View key={index} className='price-item'>
<Text className='price-name'>{item.name}</Text>
<Text className='price-value'>{item.price}</Text>
</View>
))}
</View>
</View>
</View>
<View className='tips-section'>
<View className='tips-card'>
<Text className='tips-title'>使</Text>
{tipsList.map((tip, index) => (
<View key={index} className='tips-item'>
<Text className='tips-number'>{index + 1}</Text>
<Text className='tips-text'>{tip}</Text>
</View>
))}
</View>
</View> </View>
<Button className="button" onClick={() => setTitle("Taro Hooks Nice!")}>
</Button>
<Button className="button" onClick={handleModal}>
使Modal
</Button>
</View> </View>
); );
}; };

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '登录',
};

367
src/pages/login/index.css Normal file
View File

@@ -0,0 +1,367 @@
/* ============================================
登录页面样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
page {
background: linear-gradient(180deg, #e8f0fe 0%, #f0f5ff 30%, #fafbfc 100%);
min-height: 100vh;
}
.login-page {
min-height: 100vh;
padding: 100px 32px 60px;
display: flex;
flex-direction: column;
}
/* 头部区域 */
.login-header {
text-align: center;
margin-bottom: 48px;
animation: fadeInDown 0.6s ease forwards;
}
.login-logo {
width: 80px;
height: 80px;
background: var(--primary-gradient);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
box-shadow: 0 8px 24px rgba(90, 147, 223, 0.3);
font-size: 40px;
}
.login-title {
font-size: 40px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 2px;
display: block;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 24px;
color: var(--text-tertiary);
display: block;
}
/* 表单卡片 */
.login-form {
background: rgba(255, 255, 255, 0.95);
border-radius: var(--radius-xl);
padding: 36px 28px;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
animation: slideUp 0.6s ease 0.2s forwards;
opacity: 0;
}
.form-item {
margin-bottom: 24px;
}
.form-label {
font-size: var(--font-size-base);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
display: block;
margin-bottom: 12px;
padding-left: 4px;
}
.form-input {
width: 100%;
height: 88px;
background: #f8f9fb;
border-radius: var(--radius-md);
padding: 0 24px;
font-size: var(--font-size-base);
color: var(--text-primary);
border: 1px solid var(--border-primary);
transition: all var(--transition-base);
}
.form-input:focus {
border-color: var(--primary-color);
background: #fff;
box-shadow: 0 0 0 4px rgba(90, 147, 223, 0.1);
}
.form-input::placeholder {
color: var(--text-placeholder);
}
.form-error {
font-size: var(--font-size-xs);
color: var(--error-color);
margin-top: 8px;
display: block;
padding-left: 4px;
}
/* 验证码输入区域 */
.code-input-wrapper {
display: flex;
gap: 16px;
}
.code-input {
flex: 1;
}
.code-btn {
width: 140px;
height: 88px;
background: var(--primary-gradient);
color: #fff;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition-base);
white-space: nowrap;
}
.code-btn::after {
border: none;
}
.code-btn:active {
transform: scale(0.96);
}
.code-btn[disabled],
.code-btn.disabled {
background: linear-gradient(135deg, #c5cede 0%, #b0bccf 100%);
color: rgba(255, 255, 255, 0.7);
}
/* 登录按钮 */
.login-btn {
width: 100%;
height: 96px;
background: var(--primary-gradient);
color: #fff;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
border-radius: 48px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 32px;
box-shadow: 0 8px 24px rgba(90, 147, 223, 0.3);
transition: all var(--transition-base);
letter-spacing: 2px;
}
.login-btn::after {
border: none;
}
.login-btn:active {
transform: scale(0.97);
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.2);
}
.login-btn[disabled],
.login-btn.disabled {
background: linear-gradient(135deg, #c5cede 0%, #b0bccf 100%);
box-shadow: none;
}
/* 分割线 */
.divider {
display: flex;
align-items: center;
margin: 36px 0;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--border-primary);
}
.divider-text {
padding: 0 20px;
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 微信登录按钮 */
.wechat-btn {
width: 100%;
height: 96px;
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
color: #fff;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
border-radius: 48px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(7, 193, 96, 0.3);
transition: all var(--transition-base);
letter-spacing: 2px;
}
.wechat-btn::after {
border: none;
}
.wechat-btn:active {
transform: scale(0.97);
}
/* 底部链接 */
.login-footer {
display: flex;
justify-content: center;
align-items: center;
margin-top: 40px;
flex-wrap: wrap;
gap: 4px;
}
.footer-text {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.link-text {
font-size: var(--font-size-xs);
color: var(--primary-color);
font-weight: var(--font-weight-medium);
}
/* 昵称弹窗 */
.nickname-modal {
background: rgba(255, 255, 255, 0.98);
border-radius: var(--radius-xl);
padding: 48px 32px;
margin-top: 100px;
box-shadow: var(--shadow-xl);
animation: modalIn 0.3s ease forwards;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
display: block;
text-align: center;
margin-bottom: 12px;
}
.modal-subtitle {
font-size: var(--font-size-base);
color: var(--text-tertiary);
display: block;
text-align: center;
margin-bottom: 32px;
}
.nickname-input {
width: 100%;
height: 88px;
background: #f8f9fb;
border-radius: var(--radius-md);
padding: 0 24px;
font-size: var(--font-size-base);
color: var(--text-primary);
border: 1px solid var(--border-primary);
margin-bottom: 24px;
}
.nickname-input:focus {
border-color: var(--primary-color);
background: #fff;
box-shadow: 0 0 0 4px rgba(90, 147, 223, 0.1);
}
.submit-nickname-btn {
width: 100%;
height: 96px;
background: var(--primary-gradient);
color: #fff;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
border-radius: 48px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(90, 147, 223, 0.3);
}
.submit-nickname-btn::after {
border: none;
}
/* 动画 */
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 装饰元素 */
.login-page::before {
content: '';
position: fixed;
top: -100px;
right: -100px;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(125, 179, 245, 0.15) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.login-page::after {
content: '';
position: fixed;
bottom: -50px;
left: -50px;
width: 250px;
height: 250px;
background: radial-gradient(circle, rgba(125, 179, 245, 0.1) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}

232
src/pages/login/index.tsx Normal file
View File

@@ -0,0 +1,232 @@
import React, { useState, useCallback, useMemo } from "react";
import { View, Text, Button, Input } from "@tarojs/components";
import Taro from '@tarojs/taro';
import './index.css';
const Login = () => {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [loading, setLoading] = useState(false);
const [showNicknameModal, setShowNicknameModal] = useState(false);
const [nickname, setNickname] = useState('');
const handlePhoneInput = useCallback((e: any) => {
const value = e.detail.value;
const numericValue = value.replace(/\D/g, '');
const truncatedValue = numericValue.slice(0, 11);
setPhone(truncatedValue);
}, []);
const isPhoneValid = useMemo(() => {
return /^1[3-9]\d{9}$/.test(phone);
}, [phone]);
const getPhoneError = useMemo(() => {
if (phone.length === 0) return '';
if (phone.length < 11) return '请输入完整的11位手机号';
if (!/^1[3-9]/.test(phone)) return '手机号格式不正确';
return '';
}, [phone]);
const handleGetCode = useCallback(() => {
if (!phone) {
Taro.showToast({ title: '请输入手机号', icon: 'none' });
return;
}
if (!isPhoneValid) {
Taro.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
setCountdown(60);
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
Taro.showToast({ title: '验证码已发送', icon: 'success' });
}, [phone, isPhoneValid]);
const handlePhoneLogin = useCallback(async () => {
if (!phone) {
Taro.showToast({ title: '请输入手机号', icon: 'none' });
return;
}
if (!isPhoneValid) {
Taro.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
if (!code) {
Taro.showToast({ title: '请输入验证码', icon: 'none' });
return;
}
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
setShowNicknameModal(true);
} catch (error) {
Taro.showToast({ title: '登录失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [phone, isPhoneValid, code]);
const handleCreateNickname = useCallback(() => {
if (!nickname.trim()) {
Taro.showToast({ title: '请输入用户名', icon: 'none' });
return;
}
const userInfo = {
nickname: nickname.trim(),
avatar: '',
balance: 0,
};
Taro.setStorageSync('userInfo', userInfo);
Taro.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
Taro.navigateBack();
}, 1500);
}, [nickname]);
const handleWechatLogin = useCallback(async () => {
setLoading(true);
try {
const loginResult = await Taro.login();
console.log('微信登录 code:', loginResult.code);
let avatarUrl = '';
let nickName = '微信用户';
try {
const userInfoRes = await Taro.getUserProfile({
desc: '用于完善用户资料',
});
if (userInfoRes.userInfo) {
avatarUrl = userInfoRes.userInfo.avatarUrl;
nickName = userInfoRes.userInfo.nickName;
}
} catch (e) {
console.log('游客模式获取用户信息受限,使用默认信息');
}
const userInfo = {
nickname: nickName,
avatar: avatarUrl,
balance: 0,
};
Taro.setStorageSync('userInfo', userInfo);
Taro.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
Taro.navigateBack();
}, 1500);
} catch (error) {
console.error('微信登录失败:', error);
Taro.showToast({ title: '登录失败,请重试', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
if (showNicknameModal) {
return (
<View className='login-page'>
<View className='nickname-modal'>
<Text className='modal-title'></Text>
<Text className='modal-subtitle'>便</Text>
<Input
className='nickname-input'
placeholder='请输入用户名'
maxlength={20}
value={nickname}
onInput={(e) => setNickname(e.detail.value)}
/>
<Button className='confirm-btn' onClick={handleCreateNickname}>
</Button>
</View>
</View>
);
}
return (
<View className='login-page'>
<View className='login-header'>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View>
<View className='login-form'>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
type='number'
placeholder='请输入手机号'
maxlength={11}
value={phone}
onInput={handlePhoneInput}
/>
{getPhoneError ? (
<Text className='form-error'>{getPhoneError}</Text>
) : null}
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<View className='code-input-wrapper'>
<Input
className='form-input code-input'
type='number'
placeholder='请输入验证码'
maxlength={6}
value={code}
onInput={(e) => setCode(e.detail.value.replace(/\D/g, ''))}
/>
<Button
className={`code-btn ${!isPhoneValid ? 'disabled' : ''}`}
onClick={handleGetCode}
disabled={countdown > 0 || !isPhoneValid}
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</View>
</View>
<Button
className='login-btn'
onClick={handlePhoneLogin}
loading={loading}
>
</Button>
<View className='divider'>
<View className='divider-line' />
<Text className='divider-text'></Text>
<View className='divider-line' />
</View>
<Button
className='wechat-btn'
onClick={handleWechatLogin}
loading={loading}
>
</Button>
</View>
<View className='login-footer'>
<Text className='footer-text'></Text>
<Text className='link-text'></Text>
<Text className='footer-text'></Text>
<Text className='link-text'></Text>
</View>
</View>
);
};
export default Login;

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '订单详情',
};

View File

@@ -0,0 +1,10 @@
page {
background-color: #f5f5f5;
}
.detail-page {
padding: 30px;
text-align: center;
color: #666;
font-size: 28px;
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import { View, Text } from "@tarojs/components";
import './index.css';
const OrderDetail = () => {
return (
<View className='detail-page'>
<Text></Text>
</View>
);
};
export default OrderDetail;

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '我的订单',
};

354
src/pages/order/index.css Normal file
View File

@@ -0,0 +1,354 @@
/* ============================================
订单列表页面样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
page {
background: linear-gradient(180deg, #e8f0fe 0%, #f0f5ff 30%, #fafbfc 100%);
min-height: 100vh;
}
.order-page {
min-height: 100vh;
padding: 32px 24px 140px;
}
/* 页面头部 */
.order-header {
margin-bottom: 24px;
animation: fadeInDown 0.4s ease forwards;
}
.header-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
/* 订单状态筛选 */
.status-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.status-tab {
padding: 10px 20px;
border-radius: 24px;
font-size: var(--font-size-sm);
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border-primary);
white-space: nowrap;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.status-tab:active {
transform: scale(0.95);
}
.status-tab.active {
background: var(--primary-gradient);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.2);
}
/* 订单列表 */
.order-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.order-item {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
transition: all var(--transition-base);
animation: slideUp 0.4s ease forwards;
opacity: 0;
}
.order-item:nth-child(1) { animation-delay: 0.1s; }
.order-item:nth-child(2) { animation-delay: 0.15s; }
.order-item:nth-child(3) { animation-delay: 0.2s; }
.order-item:active {
transform: scale(0.98);
box-shadow: var(--shadow-sm);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 订单顶部 */
.order-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-light);
}
.order-id {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.order-status {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
padding: 4px 12px;
border-radius: 12px;
}
.order-status.pending {
background: rgba(250, 173, 20, 0.1);
color: var(--warning-color);
}
.order-status.processing {
background: rgba(24, 144, 255, 0.1);
color: var(--info-color);
}
.order-status.completed {
background: rgba(82, 196, 26, 0.1);
color: var(--success-color);
}
.order-status.cancelled {
background: rgba(255, 77, 79, 0.1);
color: var(--error-color);
}
/* 订单中部 */
.order-middle {
margin-bottom: 16px;
}
.file-name {
font-size: var(--font-size-md);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
display: block;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-detail {
font-size: var(--font-size-sm);
color: var(--text-secondary);
display: block;
margin-bottom: 6px;
}
.printer-name {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 4px;
}
/* 订单底部 */
.order-bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid var(--border-light);
}
.order-price {
font-size: var(--font-size-xl);
color: var(--error-color);
font-weight: var(--font-weight-bold);
}
.order-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
animation: fadeIn 0.5s ease forwards;
}
.empty-icon {
font-size: 100px;
margin-bottom: 20px;
opacity: 0.4;
}
.empty-text {
font-size: var(--font-size-lg);
color: var(--text-tertiary);
font-weight: var(--font-weight-medium);
margin-bottom: 8px;
}
.empty-hint {
font-size: var(--font-size-sm);
color: var(--text-placeholder);
}
/* 订单操作按钮 */
.order-actions {
display: flex;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-light);
}
.order-action-btn {
flex: 1;
height: 64px;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
border: none;
transition: all var(--transition-fast);
}
.order-action-btn::after {
border: none;
}
.order-action-btn:active {
transform: scale(0.96);
}
.order-action-btn.primary {
background: var(--primary-gradient);
color: #fff;
box-shadow: 0 4px 12px rgba(90, 147, 223, 0.2);
}
.order-action-btn.secondary {
background: #f5f5f7;
color: var(--text-secondary);
}
/* 进度指示器 */
.order-progress {
display: flex;
align-items: center;
margin: 16px 0;
}
.progress-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.progress-step::before {
content: '';
position: absolute;
top: 14px;
left: 50%;
width: 100%;
height: 2px;
background: var(--border-light);
}
.progress-step:last-child::before {
display: none;
}
.progress-step.completed::before {
background: var(--success-color);
}
.progress-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--border-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-tertiary);
z-index: 1;
}
.progress-step.completed .progress-dot {
background: var(--success-color);
color: #fff;
}
.progress-step.current .progress-dot {
background: var(--primary-color);
color: #fff;
animation: pulse 2s ease infinite;
}
.progress-label {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-top: 8px;
}
.progress-step.completed .progress-label,
.progress-step.current .progress-label {
color: var(--text-secondary);
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}

90
src/pages/order/index.tsx Normal file
View File

@@ -0,0 +1,90 @@
import React, { useState } from "react";
import { View, Text, Button } from "@tarojs/components";
import Taro from '@tarojs/taro';
import './index.css';
interface OrderItem {
id: number;
fileName: string;
printerName: string;
printType: string;
colorType: string;
copies: number;
totalPrice: number;
status: string;
statusColor: string;
time: string;
}
const OrderIndex = () => {
const [orders] = useState<OrderItem[]>([
{
id: 10001,
fileName: '期末考试复习资料.docx',
printerName: '图书馆A打印机',
printType: 'A4',
colorType: '黑白',
copies: 1,
totalPrice: 0.10,
status: '已完成',
statusColor: '#52c41a',
time: '2026-03-30 10:30'
},
{
id: 10002,
fileName: '毕业设计.pdf',
printerName: '图书馆B打印机',
printType: 'A4',
colorType: '彩色',
copies: 2,
totalPrice: 1.20,
status: '打印中',
statusColor: '#1890ff',
time: '2026-03-29 15:20'
},
]);
const handleViewDetail = (orderId: number) => {
Taro.navigateTo({
url: `/pages/order/detail/index?orderId=${orderId}`
});
};
return (
<View className='order-page'>
<View className='order-header'>
<Text className='header-title'></Text>
</View>
{orders.length > 0 ? (
<View className='order-list'>
{orders.map(order => (
<View key={order.id} className='order-item' onClick={() => handleViewDetail(order.id)}>
<View className='order-top'>
<Text className='order-id'>: {order.id}</Text>
<Text className='order-status' style={{ color: order.statusColor }}>{order.status}</Text>
</View>
<View className='order-middle'>
<Text className='file-name'>{order.fileName}</Text>
<Text className='order-detail'>{order.printType} {order.colorType} × {order.copies}</Text>
<Text className='printer-name'>📍 {order.printerName}</Text>
</View>
<View className='order-bottom'>
<Text className='order-price'>¥{order.totalPrice.toFixed(2)}</Text>
<Text className='order-time'>{order.time}</Text>
</View>
</View>
))}
</View>
) : (
<View className='empty-state'>
<Text className='empty-icon'>📋</Text>
<Text className='empty-text'></Text>
<Text className='empty-hint'></Text>
</View>
)}
</View>
);
};
export default OrderIndex;

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '确认订单',
};

View File

@@ -0,0 +1,10 @@
page {
background-color: #f5f5f5;
}
.confirm-page {
padding: 30px;
text-align: center;
color: #666;
font-size: 28px;
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import { View, Text } from "@tarojs/components";
import './index.css';
const PrintConfirm = () => {
return (
<View className='confirm-page'>
<Text></Text>
</View>
);
};
export default PrintConfirm;

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '打印设置',
};

10
src/pages/print/index.css Normal file
View File

@@ -0,0 +1,10 @@
page {
background-color: #f5f5f5;
}
.print-page {
padding: 30px;
text-align: center;
color: #666;
font-size: 28px;
}

13
src/pages/print/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import { View, Text } from "@tarojs/components";
import './index.css';
const PrintIndex = () => {
return (
<View className='print-page'>
<Text></Text>
</View>
);
};
export default PrintIndex;

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '用户中心',
};

250
src/pages/user/index.css Normal file
View File

@@ -0,0 +1,250 @@
/* ============================================
用户中心页面样式 - 爱印通
============================================ */
@import '../../styles/variables.css';
page {
background: linear-gradient(180deg, #e8f0fe 0%, #f0f5ff 30%, #fafbfc 100%);
min-height: 100vh;
}
.user-page {
min-height: 100vh;
padding-bottom: 140px;
}
/* 用户头部 */
.user-header {
background: var(--primary-gradient);
padding: 80px 32px 60px;
position: relative;
overflow: hidden;
border-radius: 0 0 40px 40px;
}
.user-header::before {
content: '';
position: absolute;
top: -30%;
right: -15%;
width: 350px;
height: 350px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.user-header::after {
content: '';
position: absolute;
bottom: -20%;
left: -10%;
width: 250px;
height: 250px;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
animation: float 8s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-15px) scale(1.05);
}
}
.user-info,
.login-tip {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
/* 头像 */
.avatar {
width: 100px;
height: 100px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
border: 3px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.avatar-default {
width: 100px;
height: 100px;
background: rgba(255, 255, 255, 0.25);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
border: 3px solid rgba(255, 255, 255, 0.4);
}
.avatar-text {
font-size: 48px;
color: #fff;
font-weight: var(--font-weight-bold);
}
.avatar-icon {
font-size: 48px;
opacity: 0.8;
}
/* 用户信息 */
.nickname {
font-size: var(--font-size-2xl);
color: #fff;
font-weight: var(--font-weight-semibold);
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.balance {
font-size: var(--font-size-base);
color: rgba(255, 255, 255, 0.85);
}
/* 登录提示 */
.login-text {
font-size: var(--font-size-lg);
color: #fff;
font-weight: var(--font-weight-medium);
opacity: 0.9;
}
.login-tip:active {
opacity: 0.7;
}
/* 菜单区域 */
.menu-section {
margin: 24px;
background: var(--bg-card);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
.menu-item {
display: flex;
align-items: center;
padding: 24px 22px;
border-bottom: 1px solid var(--border-light);
transition: background var(--transition-fast);
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:active {
background: var(--bg-hover);
}
.menu-icon {
font-size: 32px;
margin-right: 16px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.menu-title {
flex: 1;
font-size: var(--font-size-base);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
.menu-value {
font-size: var(--font-size-base);
color: var(--error-color);
font-weight: var(--font-weight-semibold);
margin-right: 8px;
}
.menu-arrow {
font-size: 24px;
color: var(--text-placeholder);
}
/* 退出登录 */
.logout-section {
padding: 0 24px;
margin-top: 32px;
}
.logout-btn {
width: 100%;
height: 88px;
background: var(--bg-card);
color: var(--error-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
border-radius: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 77, 79, 0.15);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
}
.logout-btn::after {
border: none;
}
.logout-btn:active {
transform: scale(0.97);
background: #fff5f5;
}
/* 菜单项图标容器 */
.menu-icon-wrapper {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.menu-icon-wrapper.primary {
background: linear-gradient(135deg, #e8f0fe 0%, #d0dcfa 100%);
}
.menu-icon-wrapper.success {
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
}
.menu-icon-wrapper.warning {
background: linear-gradient(135deg, #fffbe6 0%, #ffe58f 100%);
}
.menu-icon-wrapper.error {
background: linear-gradient(135deg, #fff1f0 0%, #ffccc7 100%);
}
/* 余额高亮 */
.balance-highlight {
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

116
src/pages/user/index.tsx Normal file
View File

@@ -0,0 +1,116 @@
import React, { useEffect, useState } from "react";
import { View, Text, Button } from "@tarojs/components";
import Taro from '@tarojs/taro';
import './index.css';
interface UserInfo {
nickname: string;
avatar: string;
balance: number;
}
const UserIndex = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
useEffect(() => {
checkLoginStatus();
}, []);
const checkLoginStatus = () => {
const storedUser = Taro.getStorageSync('userInfo');
if (storedUser) {
setIsLoggedIn(true);
setUserInfo(storedUser);
}
};
const menuItems = [
{ icon: '💰', title: '我的余额', value: userInfo ? `¥${userInfo.balance.toFixed(2)}` : '¥0.00', path: '' },
{ icon: '📁', title: '我的文件', value: '', path: '/pages/file/index' },
{ icon: '📋', title: '我的订单', value: '', path: '/pages/order/index' },
{ icon: '📖', title: '使用指南', value: '', path: '' },
{ icon: '❓', title: '帮助中心', value: '', path: '' },
{ icon: '⚙️', title: '设置', value: '', path: '' },
];
const handleMenuClick = (path: string) => {
if (path) {
Taro.navigateTo({ url: path });
}
};
const handleLogin = () => {
Taro.navigateTo({ url: '/pages/login/index' });
};
const handleLogout = () => {
Taro.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
Taro.removeStorageSync('userInfo');
Taro.removeStorageSync('token');
setIsLoggedIn(false);
setUserInfo(null);
Taro.showToast({ title: '已退出登录', icon: 'success' });
}
}
});
};
return (
<View className='user-page'>
<View className='user-header'>
{isLoggedIn && userInfo ? (
<View className='user-info'>
<View className='avatar'>
{userInfo.avatar ? (
<View className='avatar-img-wrapper'>
<Text className='avatar-img-text' style={{fontSize: '0'}}>{userInfo.avatar}</Text>
</View>
) : (
<View className='avatar-default'>
<Text className='avatar-text'>{userInfo.nickname[0]?.toUpperCase() || 'U'}</Text>
</View>
)}
</View>
<Text className='nickname'>{userInfo.nickname}</Text>
<Text className='balance'>: ¥{userInfo.balance.toFixed(2)}</Text>
</View>
) : (
<View className='login-tip' onClick={handleLogin}>
<View className='avatar-placeholder'>
<Text className='avatar-icon'>👤</Text>
</View>
<Text className='login-text'></Text>
</View>
)}
</View>
<View className='menu-section'>
{menuItems.map((item, index) => (
<View
key={index}
className='menu-item'
onClick={() => handleMenuClick(item.path)}
>
<Text className='menu-icon'>{item.icon}</Text>
<Text className='menu-title'>{item.title}</Text>
{item.value && item.value !== '¥0.00' && <Text className='menu-value'>{item.value}</Text>}
{!item.value && <Text className='menu-arrow'></Text>}
</View>
))}
</View>
{isLoggedIn && (
<View className='logout-section'>
<Button className='logout-btn' onClick={handleLogout}>退</Button>
</View>
)}
</View>
);
};
export default UserIndex;

5
src/store/hooks.ts Normal file
View File

@@ -0,0 +1,5 @@
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<ReturnType<typeof import('./index').store.getState>> = useSelector;

17
src/store/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './slices/userSlice';
import { orderReducer } from './slices/orderSlice';
import { fileReducer } from './slices/fileSlice';
import { printerReducer } from './slices/printerSlice';
export const store = configureStore({
reducer: {
user: userReducer,
order: orderReducer,
file: fileReducer,
printer: printerReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,44 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { FileInfo } from '../../types';
interface FileState {
fileList: FileInfo[];
uploading: boolean;
uploadProgress: number;
total: number;
}
const initialState: FileState = {
fileList: [],
uploading: false,
uploadProgress: 0,
total: 0,
};
export const fileSlice = createSlice({
name: 'file',
initialState,
reducers: {
setFileList: (state, action: PayloadAction<{ files: FileInfo[]; total: number }>) => {
state.fileList = action.payload.files;
state.total = action.payload.total;
},
addFile: (state, action: PayloadAction<FileInfo>) => {
state.fileList.unshift(action.payload);
state.total += 1;
},
removeFile: (state, action: PayloadAction<number>) => {
state.fileList = state.fileList.filter(f => f.id !== action.payload);
state.total -= 1;
},
setUploading: (state, action: PayloadAction<boolean>) => {
state.uploading = action.payload;
},
setUploadProgress: (state, action: PayloadAction<number>) => {
state.uploadProgress = action.payload;
},
},
});
export const { setFileList, addFile, removeFile, setUploading, setUploadProgress } = fileSlice.actions;
export const fileReducer = fileSlice.reducer;

View File

@@ -0,0 +1,49 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { Order } from '../../types';
interface OrderState {
currentOrder: Order | null;
orderList: Order[];
total: number;
loading: boolean;
}
const initialState: OrderState = {
currentOrder: null,
orderList: [],
total: 0,
loading: false,
};
export const orderSlice = createSlice({
name: 'order',
initialState,
reducers: {
setCurrentOrder: (state, action: PayloadAction<Order | null>) => {
state.currentOrder = action.payload;
},
setOrderList: (state, action: PayloadAction<{ orders: Order[]; total: number }>) => {
state.orderList = action.payload.orders;
state.total = action.payload.total;
},
addOrder: (state, action: PayloadAction<Order>) => {
state.orderList.unshift(action.payload);
state.total += 1;
},
updateOrder: (state, action: PayloadAction<Order>) => {
const index = state.orderList.findIndex(o => o.id === action.payload.id);
if (index !== -1) {
state.orderList[index] = action.payload;
}
if (state.currentOrder?.id === action.payload.id) {
state.currentOrder = action.payload;
}
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
},
});
export const { setCurrentOrder, setOrderList, addOrder, updateOrder, setLoading } = orderSlice.actions;
export const orderReducer = orderSlice.reducer;

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { Printer } from '../../types';
interface PrinterState {
list: Printer[];
selected: Printer | null;
loading: boolean;
}
const initialState: PrinterState = {
list: [],
selected: null,
loading: false,
};
export const printerSlice = createSlice({
name: 'printer',
initialState,
reducers: {
setPrinterList: (state, action: PayloadAction<Printer[]>) => {
state.list = action.payload;
},
setSelectedPrinter: (state, action: PayloadAction<Printer | null>) => {
state.selected = action.payload;
},
updatePrinter: (state, action: PayloadAction<Printer>) => {
const index = state.list.findIndex(p => p.id === action.payload.id);
if (index !== -1) {
state.list[index] = action.payload;
}
if (state.selected?.id === action.payload.id) {
state.selected = action.payload;
}
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
},
});
export const { setPrinterList, setSelectedPrinter, updatePrinter, setLoading } = printerSlice.actions;
export const printerReducer = printerSlice.reducer;

View File

@@ -0,0 +1,51 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { UserInfo } from '../../types';
interface UserState {
info: UserInfo | null;
token: string | null;
refreshToken: string | null;
isLoggedIn: boolean;
loading: boolean;
}
const initialState: UserState = {
info: null,
token: null,
refreshToken: null,
isLoggedIn: false,
loading: false,
};
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action: PayloadAction<{ user: UserInfo; token: string; refreshToken: string }>) => {
state.info = action.payload.user;
state.token = action.payload.token;
state.refreshToken = action.payload.refreshToken;
state.isLoggedIn = true;
},
updateUserInfo: (state, action: PayloadAction<Partial<UserInfo>>) => {
if (state.info) {
state.info = { ...state.info, ...action.payload };
}
},
setToken: (state, action: PayloadAction<string>) => {
state.token = action.payload;
},
logout: (state) => {
state.info = null;
state.token = null;
state.refreshToken = null;
state.isLoggedIn = false;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
},
});
export const { setUser, updateUserInfo, setToken, logout, setLoading } = userSlice.actions;
export const userReducer = userSlice.reducer;

470
src/styles/animations.css Normal file
View File

@@ -0,0 +1,470 @@
/* ============================================
微交互和动画效果 - 爱印通
============================================ */
@import './variables.css';
/* ============================================
进入动画
============================================ */
/* 淡入 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 淡入上浮 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 淡入下沉 */
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 从左淡入 */
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 从右淡入 */
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 缩放淡入 */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 弹窗动画 */
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 弹窗关闭 */
@keyframes modalOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
}
/* ============================================
持续动画
============================================ */
/* 脉冲 */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
/* 轻微脉冲 */
@keyframes pulseSoft {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.02);
opacity: 0.9;
}
}
/* 闪烁 */
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 旋转 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 摇摆 */
@keyframes swing {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(3deg);
}
75% {
transform: rotate(-3deg);
}
}
/* 浮动 */
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
/* 弹跳 */
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
/* 光泽扫过 */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 进度条动画 */
@keyframes progressStripes {
from {
background-position: 0 0;
}
to {
background-position: 40px 0;
}
}
/* ============================================
交互动画类
============================================ */
/* 点击反馈 */
.tap-feedback {
transition: transform 0.15s ease, opacity 0.15s ease;
}
.tap-feedback:active {
transform: scale(0.96);
opacity: 0.9;
}
/* 悬停反馈用于支持hover的设备 */
@media (hover: hover) {
.hover-elevate {
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.hover-elevate:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
}
/* 卡片进入动画 */
.animate-card-enter {
animation: fadeInUp 0.4s ease forwards;
}
/* 列表项动画延迟 */
.animate-stagger-1 { animation-delay: 0.05s; }
.animate-stagger-2 { animation-delay: 0.1s; }
.animate-stagger-3 { animation-delay: 0.15s; }
.animate-stagger-4 { animation-delay: 0.2s; }
.animate-stagger-5 { animation-delay: 0.25s; }
/* ============================================
加载状态动画
============================================ */
/* 加载旋转器 */
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner.small {
width: 20px;
height: 20px;
border-width: 2px;
}
.loading-spinner.large {
width: 48px;
height: 48px;
border-width: 4px;
}
/* 骨架屏动画 */
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e8e8e8 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
.skeleton-text {
height: 16px;
margin-bottom: 8px;
}
.skeleton-text.short {
width: 60%;
}
.skeleton-title {
height: 24px;
width: 40%;
margin-bottom: 12px;
}
.skeleton-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
.skeleton-button {
height: 44px;
width: 120px;
}
/* ============================================
状态指示器动画
============================================ */
/* 成功动画 */
@keyframes successCheck {
0% {
stroke-dashoffset: 100;
}
100% {
stroke-dashoffset: 0;
}
}
/* 错误抖动 */
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
}
/* 通知弹跳 */
@keyframes notificationBounce {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.95);
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* ============================================
实用动画类
============================================ */
.animate-fade-in {
animation: fadeIn 0.3s ease forwards;
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease forwards;
}
.animate-fade-in-down {
animation: fadeInDown 0.4s ease forwards;
}
.animate-scale-in {
animation: scaleIn 0.3s ease forwards;
}
.animate-pulse {
animation: pulse 2s ease infinite;
}
.animate-pulse-soft {
animation: pulseSoft 3s ease infinite;
}
.animate-blink {
animation: blink 1s ease infinite;
}
.animate-spin {
animation: spin 0.8s linear infinite;
}
.animate-swing {
animation: swing 1s ease infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-bounce {
animation: bounce 2s ease-in-out infinite;
}
.animate-shake {
animation: shake 0.5s ease;
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* ============================================
过渡效果
============================================ */
.transition-all {
transition: all var(--transition-base);
}
.transition-fast {
transition: all var(--transition-fast);
}
.transition-slow {
transition: all var(--transition-slow);
}
.transition-opacity {
transition: opacity var(--transition-base);
}
.transition-transform {
transition: transform var(--transition-base);
}
.transition-colors {
transition: color var(--transition-base), background-color var(--transition-base), border-color var(--transition-base);
}
/* ============================================
页面切换动画
============================================ */
.page-enter {
animation: fadeInRight 0.3s ease forwards;
}
.page-leave {
animation: fadeInLeft 0.3s ease reverse forwards;
}
/* 模态框动画 */
.modal-overlay-enter {
animation: fadeIn 0.2s ease forwards;
}
.modal-content-enter {
animation: modalIn 0.3s ease forwards;
}
.modal-overlay-leave {
animation: fadeIn 0.2s ease reverse forwards;
}
.modal-content-leave {
animation: modalOut 0.2s ease forwards;
}

325
src/styles/common.css Normal file
View File

@@ -0,0 +1,325 @@
/* ============================================
全局通用样式 - 爱印通
============================================ */
@import './variables.css';
/* 重置样式 */
page {
background: var(--bg-page);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
/* 通用容器 */
.container {
padding: 0 var(--spacing-lg);
}
/* 通用卡片 */
.common-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
margin-bottom: var(--spacing-md);
}
/* 通用按钮 */
.common-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
transition: all var(--transition-base);
}
.common-btn:active {
transform: scale(0.97);
}
.common-btn.primary {
background: var(--primary-gradient);
color: var(--text-inverse);
}
.common-btn.outline {
background: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.common-btn.ghost {
background: rgba(90, 147, 223, 0.1);
color: var(--primary-color);
}
.common-btn.danger {
background: linear-gradient(135deg, #ff7875 0%, #ff4d4f 100%);
color: var(--text-inverse);
}
.common-btn.disabled,
.common-btn[disabled] {
opacity: 0.5;
pointer-events: none;
}
/* 通用输入框 */
.common-input {
width: 100%;
height: 88px;
background: #f8f9fb;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
padding: 0 var(--spacing-md);
font-size: var(--font-size-base);
color: var(--text-primary);
transition: all var(--transition-base);
}
.common-input:focus {
border-color: var(--primary-color);
background: #fff;
box-shadow: 0 0 0 3px rgba(90, 147, 223, 0.1);
}
.common-input::placeholder {
color: var(--text-placeholder);
}
/* 通用标签 */
.common-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: var(--radius-xs);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.common-tag.primary {
background: rgba(90, 147, 223, 0.1);
color: var(--primary-color);
}
.common-tag.success {
background: rgba(82, 196, 26, 0.1);
color: var(--success-color);
}
.common-tag.warning {
background: rgba(250, 173, 20, 0.1);
color: var(--warning-color);
}
.common-tag.error {
background: rgba(255, 77, 79, 0.1);
color: var(--error-color);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-2xl) 0;
}
.empty-state .empty-icon {
font-size: 120px;
margin-bottom: var(--spacing-lg);
opacity: 0.5;
}
.empty-state .empty-text {
font-size: var(--font-size-lg);
color: var(--text-tertiary);
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-xs);
}
.empty-state .empty-hint {
font-size: var(--font-size-sm);
color: var(--text-placeholder);
}
/* 加载状态 */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2xl) 0;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 分割线 */
.divider {
display: flex;
align-items: center;
margin: var(--spacing-lg) 0;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--border-primary);
}
.divider-text {
padding: 0 var(--spacing-md);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 文本样式 */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
.text-placeholder {
color: var(--text-placeholder);
}
.text-brand {
color: var(--primary-color);
}
.text-success {
color: var(--success-color);
}
.text-warning {
color: var(--warning-color);
}
.text-error {
color: var(--error-color);
}
/* 字体大小 */
.text-xs {
font-size: var(--font-size-xs);
}
.text-sm {
font-size: var(--font-size-sm);
}
.text-base {
font-size: var(--font-size-base);
}
.text-md {
font-size: var(--font-size-md);
}
.text-lg {
font-size: var(--font-size-lg);
}
.text-xl {
font-size: var(--font-size-xl);
}
/* 字体粗细 */
.font-normal {
font-weight: var(--font-weight-normal);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
.font-semibold {
font-weight: var(--font-weight-semibold);
}
.font-bold {
font-weight: var(--font-weight-bold);
}
/* 间距工具类 */
.mt-xs { margin-top: var(--spacing-xs); }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mt-lg { margin-top: var(--spacing-lg); }
.mt-xl { margin-top: var(--spacing-xl); }
.mb-xs { margin-bottom: var(--spacing-xs); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.mb-xl { margin-bottom: var(--spacing-xl); }
.p-xs { padding: var(--spacing-xs); }
.p-sm { padding: var(--spacing-sm); }
.p-md { padding: var(--spacing-md); }
.p-lg { padding: var(--spacing-lg); }
.p-xl { padding: var(--spacing-xl); }
/* Flex布局 */
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
/* 截断文本 */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 安全区域 */
.safe-area-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}

196
src/styles/variables.css Normal file
View File

@@ -0,0 +1,196 @@
/* ============================================
全局主题变量 - 爱印通设计系统
============================================ */
:root {
/* 主色调 - 科技蓝 */
--primary-color: #5a93df;
--primary-light: #7db3f5;
--primary-dark: #4a7bc0;
--primary-gradient: linear-gradient(135deg, #7db3f5 0%, #5a93df 50%, #4a7bc0 100%);
--primary-gradient-light: linear-gradient(135deg, #8ec5f8 0%, #7db3f5 100%);
/* 辅助色 */
--success-color: #52c41a;
--success-light: #73d13d;
--warning-color: #faad14;
--warning-light: #ffc53d;
--error-color: #ff4d4f;
--error-light: #ff7875;
--info-color: #1890ff;
/* 中性色 */
--text-primary: #1a1a2e;
--text-secondary: #555566;
--text-tertiary: #888899;
--text-placeholder: #b0b0c0;
--text-inverse: #ffffff;
/* 背景色 */
--bg-page: #f5f7fa;
--bg-card: #ffffff;
--bg-hover: #f0f4f8;
--bg-overlay: rgba(0, 0, 0, 0.45);
/* 边框色 */
--border-primary: #e5e8f0;
--border-light: #f0f2f7;
--border-focus: #7db3f5;
/* 阴影 */
--shadow-sm: 0 2px 8px rgba(90, 147, 223, 0.06);
--shadow-md: 0 4px 16px rgba(90, 147, 223, 0.1);
--shadow-lg: 0 8px 32px rgba(90, 147, 223, 0.15);
--shadow-xl: 0 12px 48px rgba(90, 147, 223, 0.2);
/* 圆角 */
--radius-xs: 8px;
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-xl: 24px;
--radius-full: 50%;
/* 间距 */
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* 字体 */
--font-size-xs: 20px;
--font-size-sm: 22px;
--font-size-base: 24px;
--font-size-md: 26px;
--font-size-lg: 28px;
--font-size-xl: 30px;
--font-size-2xl: 34px;
--font-size-3xl: 40px;
--font-size-4xl: 48px;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* 动画 */
--transition-fast: 0.15s ease;
--transition-base: 0.25s ease;
--transition-slow: 0.4s ease;
/* 模糊 */
--blur-sm: 8px;
--blur-md: 16px;
--blur-lg: 24px;
}
/* 页面背景渐变 */
.bg-gradient-page {
background: linear-gradient(180deg, #eef5ff 0%, #f5f7fa 50%, #fafbfc 100%);
}
/* 主色渐变背景 */
.bg-gradient-primary {
background: var(--primary-gradient);
}
/* 玻璃态效果 */
.glass-effect {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
/* 卡片基础样式 */
.card-base {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
/* 按钮基础样式 */
.btn-primary {
background: var(--primary-gradient);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
transition: all var(--transition-base);
}
.btn-primary:active {
transform: scale(0.97);
opacity: 0.9;
}
/* 输入框基础样式 */
.input-base {
background: #f8f9fb;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
transition: all var(--transition-base);
}
.input-base:focus {
border-color: var(--border-focus);
background: #fff;
box-shadow: 0 0 0 3px rgba(90, 147, 223, 0.1);
}
/* 动画关键帧 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 动画类 */
.animate-fade-in {
animation: fadeIn 0.4s ease forwards;
}
.animate-slide-up {
animation: slideUp 0.5s ease forwards;
}
.animate-pulse {
animation: pulse 2s ease infinite;
}

20
src/types/file.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface FileInfo {
id: number;
userId: number;
fileName: string;
fileSize: number;
fileType: string;
fileUrl: string;
watermarkUrl?: string;
createdAt: string;
}
export interface UploadFileResponse {
fileId: number;
fileUrl: string;
}
export interface FileListResponse {
files: FileInfo[];
total: number;
}

16
src/types/index.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
export interface PageParams {
page: number;
pageSize: number;
}
export * from './user';
export * from './order';
export * from './file';
export * from './printer';
export * from './price';

48
src/types/order.ts Normal file
View File

@@ -0,0 +1,48 @@
export type OrderStatus = 0 | 1 | 2 | 3 | 4;
export type PayStatus = 0 | 1 | 2 | 3;
export interface Order {
id: number;
userId: number;
fileId: number;
fileName: string;
printerId: number;
printerName: string;
printType: 'A4' | 'A3' | '照片';
colorType: '黑白' | '彩色';
copies: number;
pages: number;
totalPrice: number;
orderStatus: OrderStatus;
payStatus: PayStatus;
createdAt: string;
updatedAt: string;
}
export interface CreateOrderRequest {
fileId: number;
printerId: number;
printType: 'A4' | 'A3' | '照片';
colorType: '黑白' | '彩色';
copies: number;
}
export interface OrderListResponse {
orders: Order[];
total: number;
}
export const OrderStatusMap: Record<OrderStatus, string> = {
0: '待支付',
1: '已支付',
2: '打印中',
3: '已完成',
4: '已取消',
};
export const PayStatusMap: Record<PayStatus, string> = {
0: '待支付',
1: '支付中',
2: '成功',
3: '失败',
};

17
src/types/price.ts Normal file
View File

@@ -0,0 +1,17 @@
export type PriceType = 'A4' | 'A3' | '照片';
export interface PriceItem {
id: number;
name: string;
type: PriceType;
price: number;
unit: string;
}
export const DefaultPriceList: PriceItem[] = [
{ id: 1, name: 'A4黑白打印', type: 'A4', price: 0.10, unit: '页' },
{ id: 2, name: 'A4彩色打印', type: 'A4', price: 0.30, unit: '页' },
{ id: 3, name: 'A3黑白打印', type: 'A3', price: 0.20, unit: '页' },
{ id: 4, name: 'A3彩色打印', type: 'A3', price: 0.60, unit: '页' },
{ id: 5, name: '照片打印', type: '照片', price: 2.00, unit: '张' },
];

15
src/types/printer.ts Normal file
View File

@@ -0,0 +1,15 @@
export type PrinterStatus = 0 | 1 | 2;
export interface Printer {
id: number;
name: string;
location: string;
status: PrinterStatus;
isAvailable: boolean;
}
export const PrinterStatusMap: Record<PrinterStatus, string> = {
0: '空闲',
1: '打印中',
2: '离线',
};

19
src/types/user.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface UserInfo {
id: number;
openid: string;
nickname: string;
avatar: string;
balance: number;
createdAt: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
userInfo: UserInfo;
}
export interface UpdateUserRequest {
nickname?: string;
avatar?: string;
}

28
src/utils/format.ts Normal file
View File

@@ -0,0 +1,28 @@
export const formatPrice = (price: number): string => {
return `¥${price.toFixed(2)}`;
};
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
};
export const formatDateShort = (dateString: string): string => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}-${day}`;
};

67
src/utils/request.ts Normal file
View File

@@ -0,0 +1,67 @@
import Taro from '@tarojs/taro';
import { store } from '../store';
import { logout } from '../store/slices/userSlice';
import { API_BASE_URL } from '../config/api';
interface RequestOptions {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: any;
header?: Record<string, string>;
}
export const request = async <T>(options: RequestOptions): Promise<T> => {
const { url, method = 'GET', data, header = {} } = options;
const token = store.getState().user.token;
if (token) {
header['Authorization'] = `Bearer ${token}`;
}
try {
const response = await Taro.request({
url: `${API_BASE_URL}${url}`,
method,
data,
header: {
'Content-Type': 'application/json',
...header,
},
});
if (response.statusCode === 200) {
return response.data as T;
} else if (response.statusCode === 401) {
store.dispatch(logout());
throw new Error('Unauthorized');
} else {
throw new Error(response.data?.message || 'Request failed');
}
} catch (error) {
console.error('API Error:', error);
throw error;
}
};
export const uploadFile = <T>(filePath: string, onProgress?: (progress: number) => void): Promise<T> => {
return new Promise((resolve, reject) => {
const token = store.getState().user.token;
Taro.uploadFile({
url: `${API_BASE_URL}/file/upload`,
filePath,
name: 'file',
header: {
'Authorization': token ? `Bearer ${token}` : '',
},
success: (res) => {
if (res.statusCode === 200) {
resolve(JSON.parse(res.data) as T);
} else {
reject(new Error('Upload failed'));
}
},
fail: reject,
} as any);
});
};

34
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,34 @@
import Taro from '@tarojs/taro';
const USER_TOKEN_KEY = 'user_token';
const USER_INFO_KEY = 'user_info';
export const storage = {
setToken: (token: string) => {
Taro.setStorageSync(USER_TOKEN_KEY, token);
},
getToken: (): string => {
return Taro.getStorageSync(USER_TOKEN_KEY) || '';
},
removeToken: () => {
Taro.removeStorageSync(USER_TOKEN_KEY);
},
setUserInfo: (info: any) => {
Taro.setStorageSync(USER_INFO_KEY, info);
},
getUserInfo: () => {
return Taro.getStorageSync(USER_INFO_KEY);
},
removeUserInfo: () => {
Taro.removeStorageSync(USER_INFO_KEY);
},
clearAll: () => {
Taro.clearStorageSync();
},
};