feat: initial project setup with core infrastructure and api clients

- Add environment configs for dev/staging/prod environments
- Implement centralized axios request utility with standardized error handling
- Add shared TypeScript types for API responses and requests
- Create comprehensive API client modules for all core endpoints
- Configure vue router with all application page routes
- Add icon fonts, static assets, and loading animations
- Set up project documentation and collaboration guidelines
- Remove deprecated uni-app bridge component files
This commit is contained in:
duanshuwen
2026-05-26 21:42:36 +08:00
parent ad93ca5e8e
commit 548df7020c
30 changed files with 1213 additions and 560 deletions

46
src/api/goods.ts Normal file
View File

@@ -0,0 +1,46 @@
import { request } from "../utils/request";
// 获取商品详情
export function goodsDetail(args: any) {
return request({
url: "/hotelBiz/commodity/commodityDetail",
method: "post",
data: args,
});
}
// 订单支付
export function orderPay(args: any) {
return request({
url: "/hotelBiz/trade/order",
method: "post",
data: args,
});
}
// 获取商品日价格及库存
export function commodityDailyPriceList(args: any) {
return request({
url: "/hotelBiz/commodity/commodityDailyPriceList",
method: "post",
data: args,
});
}
// 分页查询商品分类列表
export function commodityTypePageList(args: any) {
return request({
url: "/hotelBiz/commodity/commodityTypePageList",
method: "post",
data: args,
});
}
// 快速预定分页列表
export function quickBookingList(args: any) {
return request({
url: "/hotelBiz/mainScene/quickBookingList",
method: "post",
data: args,
});
}

90
src/api/home.ts Normal file
View File

@@ -0,0 +1,90 @@
import { request } from "../utils/request";
export function submitFeedback(args: any) {
return request({
url: "/hotelBiz/feedback/submitFeedback",
method: "post",
data: args,
});
}
// 首页tab场景数据
export function homeTabsData(args: any) {
return request({
url: "/hotelBiz/mainScene/queryTagListWithCache",
method: "post",
data: args,
});
}
// 首页tab场景下内容数据
export function homeTabContentData(args: any) {
return request({
url: "/hotelBiz/mainScene/queryContentListWithCache",
method: "post",
data: args,
});
}
// 获取距离用户最近的标签
export function getNearbyTags(args: any) {
return request({
url: "/hotelBiz/mainScene/nearestTag",
method: "get",
params: args,
});
}
// 获取本地天气
export function getLocalWeather(args: any) {
return request({
url: "/hotelBiz/mainScene/getLocalWeather",
method: "get",
params: args,
});
}
// 获取时间公告列表
export function getTimeNoticeList(args: any) {
return request({
url: "/hotelBiz/mainScene/eventList",
method: "get",
params: args,
});
}
// 快速问题列表
export function homeQuickQuestionData(args: any) {
return request({
url: "/hotelBiz/mainScene/queryQuickQuestionListWithCache",
method: "post",
data: args,
});
}
// 主页数据
export function mainPageData(args: any) {
return request({
url: "/hotelBiz/mainScene/mainPageData",
method: "post",
data: args,
});
}
// 快速预订组件
export function quickBookingComponent(selectedData: any) {
const args = { selectedData: selectedData };
return request({
url: "/hotelBiz/mainScene/quickBookingComponent",
method: "post",
data: args,
});
}
// 探索发现卡片组件
export function discoveryCradComponent() {
return request({
url: "/hotelBiz/mainScene/discoveryComponent",
method: "get",
});
}

55
src/api/login.ts Normal file
View File

@@ -0,0 +1,55 @@
import { request } from "../utils/request";
// 获取oauth token
export function oauthToken(args: any) {
return request({
url: "/auth/oauth2/token",
method: "post",
data: args,
});
}
// 绑定用户手机号
export function bindUserPhone(args: any) {
return request({
url: "/hotelBiz/user/bindUserPhone",
method: "post",
data: args,
});
}
// 检测用户是否绑定手机号
export function checkUserPhone(args: any) {
return request({
url: "/hotelBiz/user/checkUserHasBindPhone",
method: "get",
params: args,
});
}
// 获取登录用户手机号
export function getLoginUserPhone(args: any) {
return request({
url: "/hotelBiz/user/getLoginUserPhone",
method: "get",
params: args,
});
}
// 获取服务协议
export function getServiceAgreement(args: any) {
return request({
url: "/hotelBiz/mainScene/serviceAgreement",
method: "get",
params: args,
});
}
// 获取隐私协议
export function getPrivacyAgreement(args: any) {
return request({
url: "/hotelBiz/mainScene/privacyPolicy",
method: "get",
params: args,
});
}

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

@@ -0,0 +1,55 @@
import { request } from "../utils/request";
// 获取用户订单列表
export function userOrderList(args: any) {
return request({
url: "/hotelBiz/order/userOrderList",
method: "post",
data: args,
});
}
// 获取订单详情
export function userOrderDetail(args: any) {
return request({
url: "/hotelBiz/order/userOrderDetail",
method: "post",
data: args,
});
}
// 预下单
export function preOrder(args: any) {
return request({
url: "/hotelBiz/trade/preOrder",
method: "post",
data: args,
});
}
// 订单取消
export function orderCancel(args: any) {
return request({
url: "/hotelBiz/trade/cancelRefund",
method: "post",
data: args,
});
}
// 申请退款
export function orderRefund(args: any) {
return request({
url: "/hotelBiz/trade/applyRefund",
method: "post",
data: args,
});
}
// 未支付订单立即支付
export function orderPayNow(args: any) {
return request({
url: "/hotelBiz/trade/applyPay",
method: "post",
data: args,
});
}

10
src/api/upload.ts Normal file
View File

@@ -0,0 +1,10 @@
import { request } from "../utils/request";
// 上传文件
export function uploadFile(args: any) {
return request({
url: "/hotelBiz/hotBizCommon/upload",
method: "post",
data: args,
});
}

36
src/api/workOrder.ts Normal file
View File

@@ -0,0 +1,36 @@
import { request } from "../utils/request";
// 获取用户订单列表
export function userWorkOrderList(args: any) {
return request({
url: "/hotelBiz/workOrder/userWorkOrderList",
method: "post",
data: args,
});
}
// 获取工单类型
export function workOrderTypeListForBiz() {
return request({
url: "/hotelBiz/workOrder/workOrderTypeListForBiz",
method: "get",
});
}
// 创建工单
export function createWorkOrder(args: any) {
return request({
url: "/hotelBiz/workOrder/createWorkOrder",
method: "post",
data: args,
});
}
// 关闭工单
export function closeWorkOrder(args: any) {
return request({
url: "/hotelBiz/workOrder/closeWorkOrder",
method: "post",
data: args,
});
}

View File

@@ -0,0 +1,17 @@
@font-face {
font-family: "ZhiNian"; /* Project id 4988933 */
src: url("/static/fonts/iconfont.ttf") format("truetype");
}
.ZhiNian {
font-family: "ZhiNian" !important;
font-style: normal;
}
.zn-food:before {
content: "\e607";
}
.zn-clock:before {
content: "\e600";
}

Binary file not shown.

View File

@@ -0,0 +1,38 @@
export const zniconsMap = {
"zn-wifi": "\ue681",
"zn-bath": "\ue69a",
"zn-frame": "\ue683",
"zn-shower-gel": "\ue684",
"zn-a-washingmachine": "\ue685",
"zn-live": "\ue686",
"zn-user": "\ue687",
"zn-dish-cover": "\ue688",
"zn-glass-cup": "\ue689",
"zn-check-circle": "\ue68a",
"zn-send-filled": "\ue68b",
"zn-nav-arrow-right": "\ue68c",
"zn-nav-arrow-left": "\ue68d",
"zn-nav-arrow-down": "\ue68e",
"zn-fast-arrow-down": "\ue68f",
"zn-nav-arrow-up": "\ue690",
"zn-microphone": "\ue691",
"zn-warning-check-in": "\ue692",
"zn-refund": "\ue693",
"zn-warning-circle": "\ue694",
"zn-dinsh": "\ue6a3",
"zn-clock": "\ue6a6",
"zn-edit": "\ue6a5",
"zn-forkchopsticks": "\ue6a4",
"zn-camera": "\ue6a2",
"zn-nav-room": "\ue6a1",
"zn-nav-ticket": "\ue6a0",
"zn-ticket": "\ue69b",
"zn-package": "\ue69d",
"zn-nav-meal": "\ue69c",
"zn-room": "\ue699",
"zn-coffee": "\ue698",
"zn-refresh": "\ue69f",
"zn-keyborad": "\ue697",
"zn-hotspring": "\ue696",
"zn-bell": "\ue695",
};

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,26 +0,0 @@
<template></template>
<style scoped></style>
<script setup>
import { onMounted } from "vue";
import { saveImageToAlbum } from "@/pages/webdiv/bridge.js";
onMounted(() => {
// 获取页面参数
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options;
if (options.imageUrl) {
const imageUrl = decodeURIComponent(options.imageUrl);
saveImage(imageUrl);
}
});
const saveImage = async (imageUrl) => {
try {
await saveImageToAlbum(imageUrl);
} catch (e) {}
uni.navigateBack();
};
</script>

View File

@@ -1,25 +0,0 @@
<template></template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { chooseAndUploadImage } from "@/pages/webdiv/bridge.js";
onLoad(() => {
handleChoose();
});
const sendResult = (imageUrl) => {
// 触发全局事件
uni.$emit("UPLOAD_RESULT", imageUrl);
};
const handleChoose = async () => {
try {
const imageUrl = await chooseAndUploadImage();
sendResult(imageUrl);
} catch (e) {
sendResult("error");
}
uni.navigateBack();
};
</script>

View File

@@ -1,288 +0,0 @@
# 工单管理系统
## 项目概述
这是一个基于 uniapp + Vue3 组合式 API 开发的微信小程序工单管理系统,提供完整的工单展示、管理和操作功能。
## 系统架构
### 页面结构
```
pages/order/
├── list.vue # 工单列表主页面
├── detail.vue # 工单详情页面
├── demo.vue # 功能演示页面
├── components/ # 组件目录
│ ├── TopNavBar/ # 顶部导航栏组件
│ ├── Tabs/ # Tab切换组件
│ ├── OrderCard/ # 工单卡片组件
│ ├── OrderList/ # 工单列表组件
│ └── ConsultationBar/ # 底部咨询栏组件
├── styles/ # 样式文件
└── images/ # 图片资源
```
## 核心组件
### 1. TopNavBar - 顶部导航栏组件
**功能特性:**
- 左侧返回按钮
- 自适应状态栏高度
- 支持自定义标题内容
- 响应式设计
**使用示例:**
```vue
<TopNavBar>
<template #title>
<Tabs :tabs="tabList" @change="handleTabChange" />
</template>
</TopNavBar>
```
### 2. Tabs - Tab切换组件
**功能特性:**
- 多标签页切换
- 动态下划线指示器
- 平滑动画过渡
- 自定义标签内容
- 固定15px宽度2px圆角下划线
**使用示例:**
```vue
<Tabs
:tabs="tabList"
:defaultActive="0"
@change="handleTabChange"
/>
```
### 3. OrderCard - 工单卡片组件
**功能特性:**
- 工单信息展示
- 多种状态支持(待处理、处理中、已完成、已取消)
- 状态图标和标签
- 操作按钮(呼叫、完成)
- 自定义操作区域
- 响应式设计
**使用示例:**
```vue
<OrderCard
:orderData="orderInfo"
@click="handleOrderClick"
@call="handleOrderCall"
@complete="handleOrderComplete"
/>
```
### 4. OrderList - 工单列表组件
**功能特性:**
- 工单列表展示
- 下拉刷新
- 上拉加载更多
- 空状态显示
- 加载状态管理
- 蓝色渐变背景
**使用示例:**
```vue
<OrderList
:orderList="orderList"
:hasMore="hasMore"
:isLoading="isLoading"
@refresh="handleRefresh"
@loadMore="handleLoadMore"
/>
```
### 5. ConsultationBar - 底部咨询栏组件
**功能特性:**
- 客服咨询入口
- 固定底部显示
- 安全区域适配
- 自定义咨询文案
- 跳转链接支持
**使用示例:**
```vue
<ConsultationBar
mainText="遇到问题?联系客服"
subText="7×24小时在线服务"
buttonText="立即咨询"
@consultation="handleConsultation"
/>
```
## 数据结构
### 工单数据结构
```javascript
{
id: String, // 工单ID
title: String, // 工单标题
createTime: String, // 创建时间
contactName: String, // 联系人姓名
contactPhone: String, // 联系电话
status: String, // 工单状态pending/processing/completed/cancelled
type: String // 工单类型service/order
}
```
### Tab数据结构
```javascript
{
label: String, // 显示文本
value: String // 值
}
```
## 功能特性
### ✅ 已实现功能
1. **工单管理**
- 工单列表展示
- 工单状态管理
- 工单详情查看
- 工单操作(呼叫、完成)
2. **交互功能**
- Tab页面切换
- 下拉刷新
- 上拉加载更多
- 一键拨号
- 客服咨询
3. **UI/UX**
- 响应式设计
- 暗色模式支持
- 流畅动画效果
- 优雅的加载状态
- 空状态处理
4. **技术特性**
- Vue3 组合式 API
- TypeScript 支持
- 组件化架构
- SCSS 样式管理
- 错误处理机制
## 使用指南
### 快速开始
1. **进入工单列表**
```javascript
uni.navigateTo({
url: '/pages/order/list'
})
```
2. **查看功能演示**
```javascript
uni.navigateTo({
url: '/pages/order/demo'
})
```
### 自定义配置
1. **修改Tab配置**
```javascript
const tabList = ref([
{ label: "全部订单", value: "all" },
{ label: "服务工单", value: "service" },
{ label: "自定义Tab", value: "custom" }
])
```
2. **自定义工单状态**
```javascript
const statusMap = {
pending: '待处理',
processing: '处理中',
completed: '已完成',
cancelled: '已取消',
custom: '自定义状态'
}
```
3. **自定义样式主题**
```scss
:root {
--primary-color: #007AFF;
--success-color: #52C41A;
--warning-color: #FF8C00;
--danger-color: #FF3B30;
}
```
## API 接口
### 工单相关接口
```javascript
// 获取工单列表
const getOrderList = async (params) => {
return await uni.request({
url: '/api/orders',
method: 'GET',
data: params
})
}
// 更新工单状态
const updateOrderStatus = async (orderId, status) => {
return await uni.request({
url: `/api/orders/${orderId}/status`,
method: 'PUT',
data: { status }
})
}
```
## 性能优化
1. **虚拟滚动**:大量数据时建议使用虚拟滚动
2. **图片懒加载**:工单图片支持懒加载
3. **防抖处理**:搜索和筛选功能防抖优化
4. **缓存机制**Tab切换时数据缓存
## 兼容性
- **微信小程序**:✅ 完全支持
- **支付宝小程序**:✅ 支持
- **H5**:✅ 支持
- **App**:✅ 支持
## 更新日志
### v1.0.0 (2024-01-15)
- 🎉 初始版本发布
- ✨ 完整的工单管理功能
- ✨ 响应式设计和暗色模式
- ✨ 组件化架构
- ✨ 完善的文档和演示
## 开发团队
- **开发者**AI Assistant
- **技术栈**uniapp + Vue3 + SCSS
- **设计规范**:微信小程序设计指南
## 许可证
MIT License
---
如有问题或建议,请联系开发团队。

View File

@@ -1,181 +0,0 @@
# 订单管理系统组件需求文档
## 项目概述
订单管理系统的核心组件库,包含工单卡片、工单列表、咨询栏等核心功能组件。
## TopNavBar 组件
### 功能要求
- 顶部导航栏展示
- 支持自定义标题内容
- 支持插槽扩展
### 设计要求
- 固定在页面顶部
- 背景色与主题一致
- 高度适中,不占用过多空间
## Tabs 组件
### 功能要求
- 标签页切换功能
- 支持默认激活项
- 切换动画效果
- 事件回调
### 设计要求
- 标签间距均匀
- 激活状态明显
- 切换动画流畅
- 响应式适配
## OrderCard 组件
### 功能要求
- 展示工单基本信息(标题、时间、联系人、状态等)
- 支持点击事件
- 支持呼叫功能
- 支持完成操作
- 状态标识清晰
### 设计要求
- 卡片式布局,圆角设计
- 信息层次分明
- 操作按钮位置合理
- 状态颜色区分明显
- 支持不同状态的视觉反馈
## OrderList 组件
### 功能要求
- 显示工单列表
- 集成z-paging组件支持虚拟列表
- 支持自定义下拉刷新(文案、样式、阈值)
- 支持自定义上拉加载更多(文案、样式、阈值)
- 自动管理空数据状态
- 支持固定高度和自适应高度模式
- 完整的事件回调机制
- 加载状态管理
### 设计要求
- 列表项间距合理
- 加载动画流畅
- 空状态友好提示
- 响应式布局
- 虚拟列表优化大数据渲染性能
### z-paging配置
- `useVirtualList`: 是否启用虚拟列表默认true
- `virtualListHeight`: 虚拟列表高度默认100%
- `cellHeightMode`: 单元格高度模式auto/fixed
- `fixedHeight`: 固定高度值当cellHeightMode为fixed时使用
- `customEmptydiv`: 是否使用自定义空状态
## ConsultationBar 组件
### 功能要求
- 底部固定咨询栏
- 显示客服信息和联系方式
- 支持立即咨询功能
- 默认隐藏,点击"立即呼叫"按钮后显示
- "立即咨询"按钮单独一行显示
- 支持显示/隐藏动画效果
### 设计要求
- 固定在页面底部
- 背景半透明或纯色
- 按钮样式与主题一致
- 信息布局清晰
- 支持安全区域适配
- 显示/隐藏动画流畅
## 数据结构
### 工单数据结构
```javascript
{
id: String, // 工单ID
title: String, // 工单标题
createTime: String, // 创建时间
contactName: String, // 联系人姓名
contactPhone: String, // 联系电话
status: String, // 状态pending-待处理, processing-处理中, completed-已完成, cancelled-已取消
type: String // 类型service-服务工单, order-普通订单
}
```
### Tab数据结构
```javascript
{
label: String, // 显示文本
value: String // 值
}
```
## 技术要求
### 框架和库
- Vue 3 Composition API
- uni-app框架
- z-paging组件用于列表优化
- SCSS样式预处理
### 性能优化
- 虚拟列表支持大数据量渲染
- 图片懒加载
- 组件按需加载
- 合理的缓存策略
### 兼容性
- 支持微信小程序
- 支持H5
- 支持APP
- 响应式设计,适配不同屏幕尺寸
## 更新日志
### v1.2.0 (最新)
- ✅ 集成z-paging组件到OrderList
- ✅ 支持虚拟列表,提升大数据渲染性能
- ✅ 自定义下拉刷新和上拉加载更多
- ✅ 自动管理空数据状态
- ✅ 支持固定高度和自适应高度模式
- ✅ 完整的事件回调机制
- ✅ 创建OrderList演示页面
### v1.1.0
- ✅ 修改ConsultationBar组件布局
- ✅ "立即咨询"按钮单独一行显示
- ✅ 默认隐藏,点击"立即呼叫"后显示
- ✅ 添加显示/隐藏动画效果
- ✅ 更新相关样式和交互逻辑
### v1.0.0
- ✅ 完成OrderCard组件开发
- ✅ 完成OrderList组件开发
- ✅ 完成ConsultationBar组件开发
- ✅ 完成TopNavBar组件开发
- ✅ 完成Tabs组件开发
- ✅ 完成订单管理页面集成
- ✅ 创建组件演示页面
- ✅ 编写技术文档

View File

@@ -1,23 +1,14 @@
<template>
<z-paging
ref="paging"
v-model="dataList"
use-virtual-list
:force-close-inner-list="true"
cell-height-mode="dynamic"
safe-area-inset-bottom
@query="queryList"
>
<z-paging ref="paging" v-model="dataList" use-virtual-list :force-close-inner-list="true" cell-height-mode="dynamic"
safe-area-inset-bottom @query="queryList">
<template #top>
<TopNavBar title="快速预定" :background="$theme - color - 100" />
<Tabs @change="handleTabChange" />
<!-- 选择入住离店日期 0:是酒店 -->
<div
v-if="didSelectedTabItem && didSelectedTabItem.orderType == 0"
class="bg-white border-box flex flex-items-center p-12"
>
<div v-if="didSelectedTabItem && didSelectedTabItem.orderType == 0"
class="bg-white border-box flex flex-items-center p-12">
<div class="in flex flex-items-center">
<span class="font-size-11 font-500 color-99A0AE mr-4">入住</span>
<span class="font-size-14 font-500 color-171717">
@@ -26,9 +17,7 @@
</div>
<!-- 几晚 -->
<span
class="nights bg-E5E8EE border-box font-size-11 font-500 color-525866 rounded-50 ml-8 mr-8"
>
<span class="nights bg-E5E8EE border-box font-size-11 font-500 color-525866 rounded-50 ml-8 mr-8">
{{ selectedDate.totalDays }}
</span>
@@ -40,42 +29,26 @@
</div>
<!-- 日期图标 -->
<uni-icons
class="ml-auto"
type="calendar"
size="24"
color="$theme-color-500"
@click="calendarVisible = true"
/>
<uni-icons class="ml-auto" type="calendar" size="24" color="$theme-color-500" @click="calendarVisible = true" />
</div>
</template>
<Card
v-for="(item, index) in dataList"
:key="index"
:item="item"
:selectedDate="selectedDate"
/>
<Card v-for="(item, index) in dataList" :key="index" :item="item" :selectedDate="selectedDate" />
</z-paging>
<!-- 日历组件 -->
<Calender
:visible="calendarVisible"
mode="range"
:default-value="selectedDate"
@close="handleCalendarClose"
@range-select="handleDateSelect"
/>
<Calender :visible="calendarVisible" mode="range" :default-value="selectedDate" @close="handleCalendarClose"
@range-select="handleDateSelect" />
</template>
<script setup>
<script setup lang="ts">
import { ref } from "vue";
import TopNavBar from "@/components/TopNavBar/index.vue";
import Calender from "@/components/Calender/index.vue";
import Tabs from "./components/Tabs/index.vue";
import Card from "./components/Card/index.vue";
import { quickBookingList } from "@/request/api/GoodsApi";
import { DateUtils } from "@/utils";
import { quickBookingList } from "@/api/goods";
import { DateUtils } from "@/utils/dateUtils";
const calendarVisible = ref(false);
const selectedDate = ref({

View File

@@ -1,7 +1,63 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
export const routes = [] satisfies RouteRecordRaw[];
export const routes = [
{
path: "/",
name: "home",
component: () => import("@/pages/home/index.vue"),
},
{
path: "/home",
name: "home_alias",
component: () => import("@/pages/home/index.vue"),
},
{
path: "/login",
name: "login",
component: () => import("@/pages/login/index.vue"),
},
{
path: "/booking",
name: "booking",
component: () => import("@/pages/booking/index.vue"),
},
{
path: "/goods",
name: "goods",
component: () => import("@/pages/goods/index.vue"),
},
{
path: "/goods/album",
name: "goods_album",
component: () => import("@/pages/goods/album/index.vue"),
},
{
path: "/quick",
name: "quick",
component: () => import("@/pages/quick/index.vue"),
},
{
path: "/quick/list",
name: "quick_list",
component: () => import("@/pages/quick/list.vue"),
},
{
path: "/order",
name: "order_list",
component: () => import("@/pages/order/order/list.vue"),
},
{
path: "/order/detail",
name: "order_detail",
component: () => import("@/pages/order/order/detail.vue"),
},
{
path: "/service/order",
name: "service_order",
component: () => import("@/pages/service/order/index.vue"),
},
] satisfies RouteRecordRaw[];
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),

View File

@@ -0,0 +1,29 @@
export interface ApiResponse<T> {
code: number;
message?: string;
data: T;
}
export interface RequestOptions {
signal?: AbortSignal;
headers?: Record<string, string>;
skipAuth?: boolean;
}
export type RequestContext = {
token: string | null;
clientId: string | null;
latitude: number | null;
longitude: number | null;
language: string | null;
};
export type NormalizedErrorKind = "business" | "http" | "network" | "unknown";
export interface NormalizedError extends Error {
kind: NormalizedErrorKind;
code?: number;
httpStatus?: number;
response?: unknown;
}

19
src/utils/dateUtils.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* 日期工具类
* 提供日期格式化功能
* */
export class DateUtils {
/**
* 格式化日期为指定格式
* @param {Date} date - 日期对象
* @param {string} format - 格式化字符串,默认值为 "yyyy-MM-dd"
* @returns {string} 格式化后的日期字符串
*/
static formatDate(date = new Date(), format = "yyyy-MM-dd") {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return format.replace("yyyy", year).replace("MM", month).replace("dd", day);
}
}

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

@@ -0,0 +1,210 @@
import axios from "axios";
import type { AxiosError, AxiosRequestConfig } from "axios";
import { getCurrentLocale } from "@/i18n";
import type {
ApiResponse,
NormalizedError,
NormalizedErrorKind,
RequestContext,
RequestOptions,
} from "@/shared/request-types";
function resolveBaseURL(): string {
const baseURL = (import.meta.env as Record<string, unknown>).VITE_API_BASE_URL;
if (typeof baseURL !== "string" || baseURL.trim() === "") {
throw new Error("Missing VITE_API_BASE_URL");
}
return baseURL;
}
function resolveTimeout(): number {
const raw = (import.meta.env as Record<string, unknown>).VITE_API_TIMEOUT_MS;
if (typeof raw !== "string" || raw.trim() === "") {
return 15000;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : 15000;
}
const http = axios.create({
baseURL: resolveBaseURL(),
timeout: resolveTimeout(),
});
let context: RequestContext = {
token: null,
clientId: null,
latitude: null,
longitude: null,
language: null,
};
export function setAuthToken(token: string | null): void {
context = { ...context, token };
}
export function setClientId(clientId: string | null): void {
context = { ...context, clientId };
}
export function setLocation(latitude: number | null, longitude: number | null): void {
context = { ...context, latitude, longitude };
}
export function setLanguage(language: string | null): void {
context = { ...context, language };
}
export function getRequestContext(): RequestContext {
return { ...context };
}
class RequestError extends Error implements NormalizedError {
kind: NormalizedErrorKind;
code?: number;
httpStatus?: number;
response?: unknown;
constructor(params: {
message: string;
kind: NormalizedErrorKind;
code?: number;
httpStatus?: number;
response?: unknown;
}) {
super(params.message);
this.name = "RequestError";
this.kind = params.kind;
this.code = params.code;
this.httpStatus = params.httpStatus;
this.response = params.response;
}
}
function resolveRequestLanguage(): string | null {
if (context.language) {
return context.language;
}
try {
return getCurrentLocale();
} catch {
return null;
}
}
function buildContextHeaders(options?: RequestOptions): Record<string, string> {
const headers: Record<string, string> = {};
if (!options?.skipAuth && context.token) {
headers.Authorization = `Bearer ${context.token}`;
}
if (context.clientId) {
headers.clientId = context.clientId;
}
if (context.latitude !== null && Number.isFinite(context.latitude)) {
headers["X-Latitude"] = String(context.latitude);
}
if (context.longitude !== null && Number.isFinite(context.longitude)) {
headers["X-Longitude"] = String(context.longitude);
}
const language = resolveRequestLanguage();
if (language) {
headers.language = language;
}
return headers;
}
function isApiResponse(value: unknown): value is ApiResponse<unknown> {
if (!value || typeof value !== "object") {
return false;
}
const v = value as Record<string, unknown>;
return typeof v.code === "number" && "data" in v;
}
function normalizeAxiosError(error: AxiosError): RequestError {
if (error.response) {
const httpStatus = error.response.status;
const message = error.message || `HTTP ${httpStatus}`;
return new RequestError({
kind: "http",
httpStatus,
message,
response: error.response.data,
});
}
return new RequestError({
kind: "network",
message: error.message || "Network Error",
});
}
export async function request<T>(
config: AxiosRequestConfig,
options: RequestOptions = {},
): Promise<ApiResponse<T>> {
try {
const ctxHeaders = buildContextHeaders(options);
const mergedHeaders: Record<string, string> = {
...ctxHeaders,
...((config.headers ?? {}) as Record<string, string>),
...(options.headers ?? {}),
};
const res = await http.request<ApiResponse<T>>({
...config,
signal: options.signal ?? config.signal,
headers: mergedHeaders,
});
const body = res.data as unknown;
if (!isApiResponse(body)) {
throw new RequestError({
kind: "unknown",
message: "Unexpected response shape",
response: body,
});
}
if (body.code !== 0) {
throw new RequestError({
kind: "business",
code: body.code,
message: body.message || "Business Error",
response: body,
});
}
return body as ApiResponse<T>;
} catch (e: unknown) {
if (e instanceof RequestError) {
throw e;
}
if (axios.isAxiosError(e)) {
throw normalizeAxiosError(e);
}
const err = e instanceof Error ? e : new Error(String(e));
throw new RequestError({
kind: "unknown",
message: err.message || "Unknown Error",
response: err,
});
}
}
export async function requestData<T>(
config: AxiosRequestConfig,
options: RequestOptions = {},
): Promise<T> {
const res = await request<T>(config, options);
return res.data;
}