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

11
.env.development Normal file
View File

@@ -0,0 +1,11 @@
# 开发环境配置
VITE_APP_ENV = 'development'
# API 基础 URL
VITE_API_BASE_URL = 'https://onefeel.brother7.cn/ingress'
# API 请求超时时间(毫秒)
VITE_API_TIMEOUT_MS = 10000
# Socket 基础 URL
VITE_SOCKET_BASE_URL = "wss://onefeel.brother7.cn/ingress/agent/ws/chat"

11
.env.production Normal file
View File

@@ -0,0 +1,11 @@
# 生产环境配置
VITE_APP_ENV = 'production'
# API 基础 URL
VITE_API_BASE_URL = 'https://biz.nianxx.cn'
# API 请求超时时间(毫秒)
VITE_API_TIMEOUT_MS = 10000
# Socket 基础 URL
VITE_SOCKET_BASE_URL = "wss://biz.nianxx.cn/ingress/agent/ws/chat"

11
.env.staging Normal file
View File

@@ -0,0 +1,11 @@
# 生产环境配置
VITE_APP_ENV = 'staging'
# API 基础 URL
VITE_API_BASE_URL = 'https://onefeel.brother7.cn/ingress'
# API 请求超时时间(毫秒)
VITE_API_TIMEOUT_MS = 10000
# Socket 基础 URL
VITE_SOCKET_BASE_URL = "wss://onefeel.brother7.cn/ingress/agent/ws/chat"

View File

@@ -0,0 +1,205 @@
# 统一请求库封装计划src/utils/request.ts 版)
## Summary
目标:把统一请求库集中封装在 `src/utils/request.ts`,类型集中在 `src/shared/`,实现:
- baseURL/timeout/取消请求Axios Web
- 默认 header 注入AuthorizationBearer、clientId、X-Latitude、X-Longitude、language
- 业务错误/网络错误/HTTP 错误的统一归一化(只抛错,不做 Toast/跳转)
- 输出说明文档到 `docs/request.md`
## Current State Analysis以实际仓库为准
已确认的事实:
- 依赖中已包含 `axios`,但 `src/` 内暂无 axios 的实际封装与使用。
- `src/utils/request.ts` 存在但为空文件,且命名疑似历史遗留。
- 代码中存在大量“缺失模块引用”(详见文末“历史遗留问题清单”),其中 `@/request/api/*` 属于最集中、最影响后续迁移的一类。
结论:
- 本次统一请求库规划要做到“新增即可用、可渐进替换”不依赖现有缺失的API 模块结构。
## Assumptions & Decisions你已确认
- 底层实现AxiosWeb
- baseURL`import.meta.env` 的环境变量读取(本计划固定变量名为 `VITE_API_BASE_URL`
- header 值来源:全局上下文注入(内存态),由登录/定位/语言切换时写入
- Authorization`Bearer <token>`token 为空则不带 Authorization
- 错误处理:请求库只做错误标准化并抛错,不做 Toast/跳转
- header 字段:`Authorization``clientId``X-Latitude``X-Longitude``language`
## 为什么不拆得更细(回答你的问题)
可以都放在 `src/utils/request.ts` 里完成,且更适合你现在“先落地一个可用的统一入口”的诉求:
- 单文件落地成本低:少目录、少入口、少导出点,替换/回滚都更直接
- 不牺牲可维护性:在单文件里用“内部分区函数 + 类型从 shared 引入”的方式,同样能保持边界清晰
- 何时再拆分:当出现“单文件 > 300~500 行、多人同时改动频繁、需要独立单测/Mock”的情况再把 errors/context/http 拆出去更合理
因此本计划采用:**实现集中在 request.ts类型抽到 shared** 的折中方案。
## Proposed Changes全新规划
### 1) 新增类型目录:`src/shared/`
新增目录:`src/shared/`
新增文件(建议):
- `src/shared/request-types.ts`
包含的类型定义(稳定、可复用):
- `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 }`
### 2) 统一请求实现:`src/utils/request.ts`
新增文件:`src/utils/request.ts`
职责(全部在一个文件内完成):
#### 2.1 上下文容器(内存态)
提供以下导出函数(由业务在合适时机调用):
- `setAuthToken(token: string | null): void`
- `setClientId(clientId: string | null): void`
- `setLocation(latitude: number | null, longitude: number | null): void`
- `setLanguage(language: string | null): void`
- `getRequestContext(): RequestContext`
默认值策略:
- `language`:如果未显式设置,尝试读取 `src/i18n/index.ts``getCurrentLocale()`(读取失败则不注入 language header
#### 2.2 Axios 实例创建与默认配置
- `baseURL``import.meta.env.VITE_API_BASE_URL`
- 若缺失:直接抛出清晰错误(提示需要配置 baseURL
- `timeout`:可选 `import.meta.env.VITE_API_TIMEOUT_MS`,缺失则默认 15000
#### 2.3 请求拦截器header 注入)
组装 headers 规则:
- 合并顺序:`默认headers < 上下文headers < 调用方options.headers`
- `Authorization`:仅当 token 存在时注入 `Bearer <token>`;当 `options.skipAuth === true` 时跳过
- `clientId`:存在则注入
- `X-Latitude` / `X-Longitude`:存在则注入(数值转字符串)
- `language`:存在则注入
#### 2.4 响应处理(业务码 + 错误归一化)
1. 正常响应:
- 若响应体符合 `ApiResponse<T>``code === 0`:返回整个 `ApiResponse<T>`
- 若响应体符合 `ApiResponse<T>``code !== 0`:抛出 `NormalizedError(kind="business", code, message, response)`
2. 异常响应axios error
-`response.status`:抛 `NormalizedError(kind="http", httpStatus, response)`
-`response`:抛 `NormalizedError(kind="network")`
对外导出(建议最小集合):
- `export async function request<T>(config, options?: RequestOptions): Promise<ApiResponse<T>>`
- 可选:`export async function requestData<T>(...): Promise<T>`(内部调用 request 并返回 `data`,用于未来更干净的调用风格)
### 3) 文档输出到 `docs/`
新增目录:`docs/`
新增文档:`docs/request.md`(必须包含):
- 环境变量约定:`VITE_API_BASE_URL``VITE_API_TIMEOUT_MS`(可选)
- 上下文写入时机:
- 登录成功:`setAuthToken(token)`
- 应用初始化:`setClientId(clientId)`
- 定位成功:`setLocation(lat, lng)`
- 语言切换:`setLanguage(locale)`(或依赖默认读取 i18n
- 使用方式:
- 旧代码风格兼容:`const res = await request<T>(); res.data...`
- 新代码推荐:`const data = await requestData<T>()`
- 错误处理示例:区分 business/http/network/unknown 并给出上层建议策略
## 历史遗留问题清单(独立梳理,方便逐个替换)
说明:以下均为“当前仓库扫描可证实”的问题点,按模块引用位置列出,便于你后续按优先级逐个替换/补齐。
### A. `@/request/api/*` 悬空引用13 个文件)
- `src/pages/quick/index.vue`
- `src/components/Feedback/index.vue`
- `src/components/CreateServiceOrder/index.vue`
- `src/pages/service/order/components/OrderCard/index.vue`
- `src/pages/booking/components/FooterSection/index.vue`
- `src/pages/goods/index.vue`
- `src/pages/order/order/components/FooterSection/index.vue`
- `src/pages/quick/components/Tabs/index.vue`
- `src/pages/order/order/list.vue`
- `src/pages/service/order/index.vue`
- `src/pages/booking/index.vue`
- `src/pages/login/index.vue`
- `src/pages/order/order/detail.vue`
建议后续替换策略:
1. 先确定“这些 API 模块的真实来源”(是否在其它分支/其它仓库/尚未迁入)。
2. 如果确认不会迁回,则逐文件把 `@/request/api/*` 替换为基于 `src/utils/request.ts` 的真实调用(需要你提供具体 URL/参数约定)。
### B. `@/utils` 目录级导入缺失6 个文件)
当前仓库没有 `src/utils/index.*`,但以下文件在导入 `@/utils`
- `src/pages/quick/index.vue`
- `src/pages/booking/components/FooterSection/index.vue`
- `src/pages/goods/index.vue`
- `src/pages/order/order/components/FooterSection/index.vue`
- `src/pages/booking/index.vue`
- `src/pages/home/index.vue`
### C. `@/store` 与实际目录 `src/stores/` 命名不一致6 个文件)
- `src/components/ImageSwiper/index.vue`
- `src/pages/quick/components/Card/index.vue`
- `src/pages/goods/album/index.vue`
- `src/pages/goods/index.vue`
- `src/pages/booking/index.vue`
- `src/pages/home/index.vue`
### D. `@/hooks/*` 缺失3 个文件)
- `src/components/SwipeCards/index.vue`
- `src/pages/login/index.vue`
- `src/pages/home/index.vue`
### E. `@/constant/*` 缺失6 个文件)
- `src/components/ModuleTitle/index.vue`
- `src/components/Feedback/index.vue`
- `src/components/SurveyQuestionnaire/index.vue`
- `src/components/CreateServiceOrder/index.vue`
- `src/pages/booking/index.vue`
- `src/pages/login/index.vue`
### F. `src/utils/request.ts`(空文件 + 疑似拼写问题)
- 该文件当前为空,且命名疑似应为 `requests``request`
- 建议后续统一策略:保留/替换/删除前先全仓搜索确认是否在其它分支被引用
## Verification执行阶段你手动确认运行
你在实现完成后手动执行:
- `yarn typecheck`
- `yarn build`
- 如有用例:`yarn test`
验收标准:
- `src/utils/request.ts` + `src/shared/request-types.ts` 编译通过
- 发起请求时能注入 headersAuthorization/clientId/X-Latitude/X-Longitude/language
- `code !== 0` 时抛出稳定结构的错误对象(上层可根据 kind 做统一处理)

77
AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# 项目协作指南
本仓库是一个基于 Vite 的 H5 Web 项目。本文档用于约束自动化 Agent 在仓库内的工作方式、目录约定与验证流程。
## 技术栈与入口
- 构建Vite 5ESM`package.json``"type": "module"`
- 框架Vue 3 + TypeScript
- 路由vue-router 4入口为 `src/router/index.ts`,当前路由配置较少/可能未完成)
- 状态管理Pinia`src/stores/`
- UIVant 4
- 样式Tailwind CSS v4通过 `@tailwindcss/vite`),并存在组件级 SCSS
- 国际化vue-i18n`src/i18n/`,含 Vant 语言同步)
- 网络/事件axios、mitt
入口文件:
- 应用入口:`src/main.ts`
- 根组件:`src/App.vue``<RouterView />`
- 全局样式/主题:`src/styles/main.css`
## 目录结构与新增代码放置
- 页面:`src/pages/<module>/...`
- 公共组件:`src/components/<ComponentName>/...`
- 国际化:`src/i18n/`
- 语言包按模块拆分:`src/i18n/modules/{common,home,quick}/`
- 工具/请求:`src/utils/`(当前存在 `requets.ts`,文件名可能为历史拼写;不要在未确认引用关系前擅自更名)
组件目录常见形态(尽量遵循):
- `src/components/Foo/index.vue`
- `src/components/Foo/styles/index.scss`
- 可选:`README.md``demo.vue``prompt.md``images/`
## 代码风格约定
- Vue 组件优先使用 `<script setup lang="ts">`
- 组件样式优先放在同目录 `styles/index.scss`,并在组件内通过 `lang="scss"` + `scoped` 方式引入/编写
- 全局主题与 Vant CSS 变量覆盖集中在 `src/styles/main.css`,避免把全局变量散落到页面/组件中
- TypeScript开启 `strict``noUnusedLocals``noUnusedParameters` 等约束;新增代码需保持零 TS 报错
- 路径别名:`@/* -> src/*`TS 与 Vite 均配置),新增引用优先使用 `@/` 形式
本仓库未集成 ESLint/Prettier 等统一格式化工具:变更时以“周边现有代码”为准保持一致性。
## 开发、构建与测试(变更后必须执行)
使用 Yarn。
- 安装依赖:`yarn`
- 本地开发:`yarn dev`Vite dev server端口 5174
- 类型检查:`yarn typecheck`
- 构建:`yarn build`(包含 `vue-tsc --noEmit`
- 预览:`yarn preview`
- 测试:`yarn test`Node 原生 test runner匹配 `src/**/*.test.ts`
说明:
- `tsconfig.json` 排除了 `src/**/*.test.ts`,测试文件如需 TS 类型支持,请在测试内保持自包含,或按项目既有方式处理
## 修改流程Agent 工作方式)
- 先定位:优先通过搜索/阅读定位真实调用链与使用方,再进行修改
- 小步提交:单次变更聚焦一个问题点;避免“顺手重构”影响范围
- 保守增依赖:不要新增第三方依赖或升级版本;确有必要时先征询确认并说明替代方案
- 不破坏接口:组件 props/事件/样式类名若被页面依赖,变更需兼容旧用法或同步修改所有调用点
- 变更必验证:完成代码修改后至少运行 `yarn typecheck`;涉及构建与运行差异时再运行 `yarn build` 与相关测试
- 开发计算时,有临时分支,需要提醒用户选择分支合并
## 限制条件
- 不要废话
- 不要缺失代码
- 不要出现伪需求
- 没有提及的代码不要修改
- 需要编译时就提醒用户手动确定
- 需要测试时就提醒用户手动执行

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# nianxx-h5
基于 Vite 的 H5 Web 项目Vue 3 + TypeScript
## 技术栈
- Vite 5
- Vue 3 + TypeScript
- vue-router 4
- Pinia
- Vant 4
- Tailwind CSS v4通过 `@tailwindcss/vite`
- vue-i18n
- axios统一请求库封装见下文
## 目录约定
- 入口:`src/main.ts`
- 路由:`src/router/index.ts`
- 页面:`src/pages/`
- 组件:`src/components/`
- 国际化:`src/i18n/`
- 全局样式:`src/styles/main.css`
- 工具:`src/utils/`
- 共享类型:`src/shared/`
## 本地开发
使用 Yarn。
```bash
yarn
yarn dev
```
常用命令:
- `yarn dev`:启动开发服务器(端口 5174
- `yarn typecheck``vue-tsc --noEmit`
- `yarn build`:类型检查 + 构建
- `yarn preview`:预览构建产物
- `yarn test`:运行 `src/**/*.test.ts`Node 原生 test runner
需要编译/测试时请手动确认执行。
## 路由
当前已在 [router/index.ts](file:///d:/www/znkj/nianxx-h5/src/router/index.ts) 配置页面路由(按 `src/pages/` 目录落地):
- `/``/home`:首页
- `/login`:登录
- `/booking`:预定
- `/goods``/goods/album`:商品详情/相册
- `/quick``/quick/list`:快速预定
- `/order``/order/detail`:订单列表/详情
- `/service/order`:工单列表
## 统一请求库
统一请求库实现:
- [request.ts](file:///d:/www/znkj/nianxx-h5/src/utils/request.ts)
- 类型定义:[request-types.ts](file:///d:/www/znkj/nianxx-h5/src/shared/request-types.ts)
- 使用文档:[docs/request.md](file:///d:/www/znkj/nianxx-h5/docs/request.md)
必须配置环境变量:
- `VITE_API_BASE_URL`
可选配置:
- `VITE_API_TIMEOUT_MS`(默认 15000
## 特殊说明uni-* 痕迹)
代码中存在 `uni.*` API、`<uni-icons>` 等用法。若最终运行环境为纯 H5 Web需要确认这些能力的提供方式注入/替换/降级)。
## 历史遗留问题清单(待逐个替换)
以下为当前仓库可证实的缺失/不一致点,建议按优先级逐步处理:
- `@/request/api/*` 在多个页面/组件被引用,但仓库内暂无对应实现
- `@/utils` 存在目录级导入,但暂无 `src/utils/index.*`
- `@/store` 被引用但实际目录为 `src/stores/`
- `@/hooks/*``@/constant/*` 被引用但当前仓库未找到对应目录
- `src/utils/requets.ts` 为空文件且命名疑似拼写遗留
更详细的引用点列表见:`.trae/documents/统一请求库封装计划.md`
## 协作规范
仓库协作约定见 [AGENTS.md](file:///d:/www/znkj/nianxx-h5/AGENTS.md)。

131
docs/request.md Normal file
View File

@@ -0,0 +1,131 @@
# 统一请求库使用说明
统一请求库实现位置:
- `src/utils/request.ts`
- 类型定义:`src/shared/request-types.ts`
## 环境变量
必须配置:
- `VITE_API_BASE_URL`:接口基础地址(例如 `https://api.example.com`
可选配置:
- `VITE_API_TIMEOUT_MS`:请求超时毫秒数(默认 15000
## 上下文注入header 来源)
请求库会在请求时自动注入以下 header有值才注入
- `Authorization: Bearer <token>`(可通过 `skipAuth: true` 跳过)
- `clientId`
- `X-Latitude`
- `X-Longitude`
- `language`
你需要在业务合适时机写入上下文(内存态):
```ts
import {
setAuthToken,
setClientId,
setLocation,
setLanguage,
} from "@/utils/request";
setClientId("your-client-id");
setAuthToken("token-string");
setLocation(30.12345, 120.12345);
setLanguage("zh-CN");
```
说明:
- `language` 如果未显式设置,会尝试从 i18n 的 `getCurrentLocale()` 获取
## 发起请求
### request<T>(兼容旧代码风格,返回 { code, message, data }
```ts
import { request } from "@/utils/request";
const res = await request<{ records: unknown[] }>({
method: "GET",
url: "/xxx/list",
params: { pageNum: 1, pageSize: 10 },
});
console.log(res.data.records);
```
### requestData<T>(推荐新代码风格,只返回 data
```ts
import { requestData } from "@/utils/request";
const data = await requestData<{ records: unknown[] }>({
method: "GET",
url: "/xxx/list",
params: { pageNum: 1, pageSize: 10 },
});
console.log(data.records);
```
### 跳过 Authorization
```ts
import { request } from "@/utils/request";
await request<string>(
{
method: "GET",
url: "/public/ping",
},
{ skipAuth: true },
);
```
### 临时追加 headers
```ts
import { request } from "@/utils/request";
await request(
{ method: "POST", url: "/xxx", data: { a: 1 } },
{ headers: { "X-Debug": "1" } },
);
```
## 错误处理(只抛错,不做 Toast
请求失败会抛出一个 `RequestError`(符合 `NormalizedError` 结构),可根据 `kind` 分流:
- `business`:后端返回 `{ code, message, data }``code !== 0`
- `http`HTTP 状态码错误(如 401/403/500
- `network`:断网/超时等(无 response
- `unknown`:返回结构不符合预期等其它情况
示例:
```ts
import type { NormalizedError } from "@/shared/request-types";
import { requestData } from "@/utils/request";
try {
await requestData({ method: "GET", url: "/xxx" });
} catch (e) {
const err = e as NormalizedError;
if (err.kind === "business") {
// err.message / err.code
} else if (err.kind === "http") {
// err.httpStatus
} else if (err.kind === "network") {
// err.message
}
}
```

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