hookehuyr

feat(mock): 新增消息模块及本地 mock 环境支持

- 新增消息详情页面及路由配置
- 重构环境配置,支持通过 API_RUNTIME_ENV 统一切换正式/mock 环境
- 添加完整的 mock 架构,包含模块化 handler、状态管理 store 和静态 fixture
- 集成 mock 到现有请求层,实现 axios 和 Taro 请求的无缝拦截
- 更新项目文档,说明 mock 目录约定和环境切换方式
......@@ -12,6 +12,11 @@
## 测试要求
当前 `package.json` 中没有独立的单元测试框架,因此默认验证方式为 `pnpm lint` 加手工联调。修改页面、路由、认证或请求逻辑后,请至少在微信开发者工具中走通相关流程。若变更涉及 `src/utils/authRedirect.js``src/utils/request.js``src/api/`,需要重点检查登录态、页面回跳和接口请求是否正常。后续如新增自动化测试,建议贴近功能放置,并使用 `*.spec.js` 命名。
## Mock 环境与接口联调约定
仓库当前支持“正式环境 / 本地 Mock 环境”两套 API 运行模式,且环境切换只允许通过配置文件统一控制,不要在页面里单独写开关。开发环境默认读取 `config/dev.js` 里的 `env.API_RUNTIME_ENV`,生产构建默认读取 `config/prod.js`;当 `API_RUNTIME_ENV``mock` 时,本地所有接口统一走 `src/mock/`,当其为 `production` 时,统一走真实接口。若只是临时联调某个页面,也不要在页面组件里直接写 `USE_MOCK_DATA` 之类的局部布尔开关,优先保持“一个地方切整个环境”的规则稳定。
Mock 目录也有明确分工:`src/mock/index.js` 只做统一入口和分发;`src/mock/modules/*.mock.js` 按业务模块注册 handler;`src/mock/shared/` 放请求解析、响应包装、handler 匹配等公共能力;`src/mock/stores/*.store.js` 只处理需要状态变化的 mock 数据;`src/mock/fixtures/*.fixture.js` 只放静态样本和数据工厂。新增 mock 时,优先按“fixture -> store(如有状态)-> module handler -> modules/index.js 注册”的顺序扩展,不要再回到一个超大 `mockData.js` 式文件里堆条件分支。页面层不应该知道 mock 细节,只应该继续调用正常的 `xxxAPI`
## 授权与支付链路约定
授权逻辑的核心在 `src/app.js``src/utils/authRedirect.js``src/utils/request.js``src/pages/auth/index`。应用启动时会优先尝试静默授权,`sessionid` 统一写入 Taro 本地缓存,并由请求拦截器动态注入到请求头;接口返回 `401` 时,会先尝试 `refreshSession` 静默续期并重放原请求,失败后再降级跳转授权页。因此除非明确重构整条链路,否则不要随意改动 `sessionid` 的存取方式、`saveCurrentPagePath` / `returnToOriginalPage` 的回跳机制、`navigateToAuth` 的防重逻辑,也不要跳过 `src/pages/auth/index` 直接在业务页硬编码授权流程。若修改分享进入、启动授权、401 重试或来源页回填逻辑,需至少手工验证一次“未授权进入页面 -> 自动或手动授权 -> 成功回跳原页面”的完整闭环。
......@@ -26,4 +31,4 @@
本仓库默认使用中文沟通与书写。今后新增或修改的说明性文字请统一使用中文,包括但不限于文档、注释说明、提交说明、PR 描述、变更摘要和协作备注;如必须保留英文术语,请同时提供中文语义,避免只写英文描述。
## 安全与配置提示
不要提交真实的 AppID、令牌或生产环境域名。首次接手项目时,请优先检查 `src/utils/config.js``project.config.json`。授权与支付测试环境当前会复用既有后端配置,调整域名、`client_id`、支付接口地址或 WebView 跳转地址前,必须先确认不会影响 `openid`、会话续期和微信支付参数生成。除非明确要重构认证链路,否则不要随意删除或绕过 `src/pages/auth/index` 认证页
不要提交真实的 AppID、令牌或生产环境域名。首次接手项目时,请优先检查 `src/utils/config.js``config/dev.js``config/prod.js``project.config.json`。授权与支付测试环境当前会复用既有后端配置,调整域名、`client_id`、支付接口地址或 WebView 跳转地址前,必须先确认不会影响 `openid`、会话续期和微信支付参数生成。除非明确要重构认证链路,否则不要随意删除或绕过 `src/pages/auth/index` 认证页;同理,除非明确要废弃本地 mock 联调能力,否则不要把 `API_RUNTIME_ENV``src/mock/index.js``src/mock/modules/` 里的统一分发能力改回页面内局部开关
......
......@@ -76,19 +76,104 @@ src/
### 1. 修改服务器配置
编辑 `src/utils/config.js`
环境读取逻辑集中在 `src/utils/config.js`,实际运行模式由构建配置决定
```javascript
const BASE_URL = process.env.NODE_ENV === 'production'
? 'https://your-production-domain.com' // 生产环境域名
: 'https://your-dev-domain.com' // 开发环境域名
export const API_ENVIRONMENTS = {
production: {
baseURL: 'https://your-production-domain.com',
requestDefaultParams: {
f: 'YOUR_MODULE',
client_id: 'YOUR_CLIENT_ID',
},
},
mock: {
baseURL: 'https://your-production-domain.com',
requestDefaultParams: {
f: 'YOUR_MODULE',
client_id: 'YOUR_CLIENT_ID',
},
useMock: true,
},
}
```
### 1.1 正式环境 / 本地 Mock 环境
仓库现在内置两套 API 运行模式:
- `production`:正式环境,走真实接口
- `mock`:本地 Mock 环境,开发态默认启用
核心文件:
export const REQUEST_DEFAULT_PARAMS = {
f: 'YOUR_MODULE', // 业务模块标识
client_name: 'YOUR_APP', // 应用名称
- `config/dev.js`:本地开发环境开关,默认 `API_RUNTIME_ENV = "mock"`
- `config/prod.js`:生产构建环境开关,默认 `API_RUNTIME_ENV = "production"`
- `src/utils/config.js`:环境定义与当前配置读取
- `src/mock/index.js`:统一 Mock 入口
- `src/mock/modules/`:按模块拆分的 Mock 处理器
- `src/mock/shared/`:请求解析、响应包装、路由匹配等公共能力
- `src/mock/stores/`:需要状态的 Mock 数据存储
- `src/mock/fixtures/`:纯静态样本和数据工厂
- `src/mock/README.md`:新增模块时的目录约定与 handler 示例
- `src/api/message.js`:消息列表 / 详情接口示例
当前已经预置了以下 mock:
- 启动授权:`/srv/?a=openid`
- 支付参数:`/srv/?a=pay``/srv/?a=icbc_pay_wxamp`
- 分享配置:`/srv/?a=wx_share`
- 短信与上传:`/srv/?a=sms``/srv/?a=upload`
- 消息示例:`/srv/?a=message&t=list``/srv/?a=message&t=detail`
如果你想让本地所有接口都走 Mock,只改一个地方就行:
```js
// config/dev.js
env: {
NODE_ENV: '"development"',
API_RUNTIME_ENV: '"mock"',
}
```
要切回本地真实接口,把它改成:
```js
API_RUNTIME_ENV: '"production"'
```
这套开关是“全局生效”的:
- `mock`:当前构建下所有接口统一走 `src/mock/`
- `production`:当前构建下所有接口统一走真实接口
页面层不要自己再写局部 `USE_MOCK_DATA` 开关,避免同一个页面和别的页面跑在不同环境里。
建议的接入顺序:
1. 先在 `src/mock/fixtures/` 里按真实字段准备样本数据。
2. 如果该接口存在状态变化,再在 `src/mock/stores/` 里补状态读写。
3.`src/mock/modules/` 里新增对应 handler,并在 `src/mock/modules/index.js` 注册。
4. 页面继续只调用正常的 `xxxAPI`,先对着 mock 跑通交互。
5. 拿到真实接口后,把 `config/dev.js` 切回 `production`,再开始联调真实 API。
### 1.2 Mock 目录约定
`src/mock/` 当前按这几个层次组织:
- `index.js`
统一入口,只负责创建请求上下文、匹配 handler、返回 axios / Taro 兼容结果。
- `modules/*.mock.js`
按模块定义接口级 handler,一个 handler 对应一个 `method + action + type` 组合。
- `stores/*.store.js`
放有状态的 mock 数据,比如消息已读、列表新增删除、详情回写等。
- `fixtures/*.fixture.js`
放纯静态样本和数据工厂,不直接处理状态变化。
- `shared/*.js`
放请求解析、响应包装、匹配规则等可复用能力。
详细约定见 [src/mock/README.md](/Users/huyirui/program/itomix/jls_weapp/src/mock/README.md)
### 2. 定义 API 接口
编辑 `src/api/index.js`,添加您的业务接口:
......@@ -136,7 +221,7 @@ export default {
- `src/utils/authRedirect.js` - 认证流程管理
- `src/utils/request.js` - HTTP 请求拦截器
**重要**后端需提供 `/srv/?a=openid_wxapp` 接口用于微信登录
**重要**当前授权链路使用的是 `/srv/?a=openid`。如果后端动作名、cookie 返回方式或公共参数发生变化,需要同时检查真实授权链路和本地 mock 授权链路是否保持一致
## 🌐 弱网/离线支持
......@@ -183,9 +268,10 @@ export default {
- Props 定义清晰,注释详细
### API 调用
- 接口统一`src/api/index.js` 定义
- 接口统一放在 `src/api/` 下按模块定义
- 使用 `xxxAPI(params)` 命名格式
- 请求方法统一使用 `src/api/fn.js` 中的封装
- 页面不要直接判断当前是否 Mock,统一让 `src/api/fn.js``src/mock/` 兜底
### 状态管理
- 使用 Pinia 进行状态管理
......
......@@ -7,7 +7,8 @@
*/
export default {
env: {
NODE_ENV: '"development"'
NODE_ENV: '"development"',
API_RUNTIME_ENV: '"mock"',
},
logger: {
quiet: false,
......
......@@ -7,7 +7,8 @@
*/
export default {
env: {
NODE_ENV: '"production"'
NODE_ENV: '"production"',
API_RUNTIME_ENV: '"production"',
},
mini: {},
h5: {
......
......@@ -8,6 +8,7 @@
import axios from '@/utils/request';
import Taro from '@tarojs/taro'
import qs from 'qs'
import { createAxiosMockResponse, shouldUseMock } from '../mock'
/**
* @description 统一后端返回格式(强制 { code, data, msg })
......@@ -83,6 +84,14 @@ export const fetch = {
* @returns {Promise<any>} axios Promise
*/
get: function (api, params) {
if (shouldUseMock()) {
return createAxiosMockResponse({
url: api,
method: 'GET',
params: params?.params || params || {},
config: { url: api, method: 'get' },
})
}
return axios.get(api, params)
},
/**
......@@ -92,6 +101,14 @@ export const fetch = {
* @returns {Promise<any>} axios Promise
*/
post: function (api, params) {
if (shouldUseMock()) {
return createAxiosMockResponse({
url: api,
method: 'POST',
data: params,
config: { url: api, method: 'post' },
})
}
return axios.post(api, params)
},
/**
......@@ -101,6 +118,17 @@ export const fetch = {
* @returns {Promise<any>} axios Promise
*/
stringifyPost: function (api, params) {
if (shouldUseMock()) {
return createAxiosMockResponse({
url: api,
method: 'POST',
data: qs.stringify(params),
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
config: { url: api, method: 'post' },
})
}
return axios.post(api, qs.stringify(params), {
headers: {
'content-type': 'application/x-www-form-urlencoded'
......@@ -115,6 +143,19 @@ export const fetch = {
* @returns {Promise<any>} axios Promise
*/
basePost: function (url, data, config) {
if (shouldUseMock()) {
return createAxiosMockResponse({
url,
method: 'POST',
data,
headers: config?.headers || {},
config: {
url,
method: 'post',
...(config || {}),
},
})
}
return axios.post(url, data, config)
}
}
......
import { fn, fetch } from './fn'
const Api = {
MESSAGE_LIST: '/srv/?a=message&t=list',
MESSAGE_DETAIL: '/srv/?a=message&t=detail',
}
export const messageListAPI = (params) => fn(fetch.get(Api.MESSAGE_LIST, { params }))
export const messageDetailAPI = (params) => fn(fetch.get(Api.MESSAGE_DETAIL, { params }))
......@@ -3,6 +3,7 @@ export default {
'pages/index/index',
'pages/map-guide/index',
'pages/message/index',
'pages/message-detail/index',
'pages/mine/index',
'pages/pay-test/index',
'pages/pay-bridge/index',
......
# Mock 目录约定
## 目标
这一层的目标不是“写一个很大的假接口文件”,而是让本地在没有真实 API 时,也能按模块稳定联调,并且后续容易替换成真实接口。
## 环境切换
全局环境由配置文件控制,不在页面里单独切换:
- 本地开发:`config/dev.js`
- 生产构建:`config/prod.js`
当前使用 `API_RUNTIME_ENV` 控制:
- `mock`:当前构建下所有接口统一走本地 Mock
- `production`:当前构建下所有接口统一走真实接口
## 目录职责
- `index.js`
统一入口,只做请求上下文创建、路由分发和响应包装。
- `modules/*.mock.js`
按业务模块定义 handler。一个 handler 对应一个接口组合:`method + action + type`
- `shared/`
放公共能力,例如请求解析、响应包装、handler 匹配。
- `stores/*.store.js`
放有状态的 mock 数据,例如“已读/未读”“新增后列表变化”这类场景。
- `fixtures/*.fixture.js`
放静态样本和数据工厂,不做业务状态修改。
## 推荐写法
### 1. 先放静态样本
如果只是字段模板、列表假数据,先放到 `fixtures/xxx.fixture.js`
### 2. 需要状态变化时再加 store
如果接口会互相影响,例如:
- 详情读取后列表变已读
- 新增后列表里能看到新增项
- 删除后再次查询不再返回
这类场景再在 `stores/xxx.store.js` 里维护状态。
### 3. handler 只做路由和调用
`modules/*.mock.js` 里尽量保持轻量:
- 判断命中哪个接口
- 调用 store / fixture
-`buildMockSuccess``buildMockError` 返回
不要把大量假数据、复杂状态、通用工具都堆到 handler 里。
## 新增一个模块时的顺序
1. 新建 `fixtures/xxx.fixture.js`
2. 如果需要状态,再新建 `stores/xxx.store.js`
3. 新建 `modules/xxx.mock.js`
4.`modules/index.js` 注册
5.`src/api/` 下补对应 API 方法
6. 在页面里直接按真实接口调用方式联调
## handler 示例
```js
export const exampleMockHandlers = [
{
action: 'example',
type: 'list',
method: 'GET',
handle: ({ requestParams }) => buildMockSuccess({
list: [],
page: Number(requestParams.page || 0),
}),
},
]
```
## 维护原则
- 模块边界优先,不要再回到“大一统 mock 文件”
- 页面不关心 mock 细节,只调用正常的 `xxxAPI`
- 能放 fixture 的,不要直接写进 handler
- 能放 store 的状态,不要散在多个 handler 里各自维护
- mock 字段名尽量贴近未来真实接口,避免后面页面再改一轮
export const MESSAGE_STATUS = {
unread: 'send',
read: 'read',
}
export const MESSAGE_TITLE_POOL = [
'法会安排更新通知',
'预约审核结果提醒',
'地图导览功能已开放',
'支付状态同步通知',
'活动报名成功提醒',
'系统维护时间说明',
]
export const MESSAGE_CONTENT_POOL = [
'这是一条用于前端联调的 Mock 消息。后续拿到真实接口后,可以直接对照字段结构替换。',
'如果页面已经能完整跑通列表、详情和已读状态,后面只需要把接口 action 与返回字段切到真实后端即可。',
'当前数据由本地 Mock 生成,适合在 API 尚未确定时提前联调页面交互和状态切换。',
]
export const createMessageFixtureList = (count = 12) => (
Array.from({ length: count }).map((_, index) => {
const id = `msg_${index + 1}`
const createdAt = new Date(Date.now() - index * 1000 * 60 * 60 * 6)
return {
id,
title: MESSAGE_TITLE_POOL[index % MESSAGE_TITLE_POOL.length],
summary: `第 ${index + 1} 条消息摘要,可用于测试列表样式与详情跳转。`,
content: `${MESSAGE_CONTENT_POOL[index % MESSAGE_CONTENT_POOL.length]}\n\n消息编号:${id}\n建议后续把这里替换成真实富文本或业务说明。`,
status: index < 4 ? MESSAGE_STATUS.unread : MESSAGE_STATUS.read,
category: index % 2 === 0 ? '系统通知' : '业务提醒',
created_time: createdAt.toISOString().slice(0, 16).replace('T', ' '),
}
})
)
import { isMockEnabled } from '../utils/config'
import { mockHandlers } from './modules'
import { createMockRequestContext } from './shared/request'
import { buildMockFallbackPayload, buildMockSuccess } from './shared/response'
import { findMockHandler } from './shared/router'
const MOCK_DELAY_MS = 160
const mockDelay = (delay = MOCK_DELAY_MS) =>
new Promise((resolve) => {
setTimeout(resolve, delay)
})
const createMockBody = (context) => {
const matchedHandler = findMockHandler(mockHandlers, context)
if (matchedHandler) {
return matchedHandler.handle(context)
}
return buildMockSuccess(buildMockFallbackPayload(context))
}
export const shouldUseMock = () => isMockEnabled()
export const createAxiosMockResponse = async (options) => {
const context = createMockRequestContext(options)
await mockDelay()
return {
data: createMockBody(context),
status: 200,
statusText: 'OK',
headers: {},
config: options?.config || {},
}
}
export const createTaroMockResponse = async (options) => {
const context = createMockRequestContext(options)
await mockDelay()
const body = createMockBody(context)
const mockCookie = `mock_sessionid=mock-${Date.now()}; Path=/; HttpOnly`
return {
data: body,
statusCode: 200,
header: {
'Set-Cookie': mockCookie,
},
cookies: [mockCookie],
}
}
import { buildMockSuccess } from '../shared/response'
export const authMockHandlers = [
{
action: 'openid',
method: 'POST',
handle: () => buildMockSuccess({
session_status: 'ok',
user_id: 'mock_user_001',
nickname: '测试账号',
}, '授权成功 (mock)'),
},
]
import { buildMockSuccess } from '../shared/response'
export const commonMockHandlers = [
{
action: 'wx_share',
method: 'GET',
handle: ({ requestParams }) => buildMockSuccess({
appId: 'mock-app-id',
timestamp: `${Math.floor(Date.now() / 1000)}`,
nonceStr: `mock_share_${Date.now()}`,
signature: 'mock-signature',
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData'],
url: requestParams?.url || '',
}, '分享配置获取成功 (mock)'),
},
{
action: 'sms',
method: 'POST',
handle: ({ requestData }) => buildMockSuccess({
sent: true,
phone: String(requestData?.phone || ''),
sms_id: `mock_sms_${Date.now()}`,
}, '验证码发送成功 (mock)'),
},
{
action: 'upload',
type: 'save_file',
method: 'POST',
handle: ({ requestData }) => buildMockSuccess({
filekey: requestData?.filekey || `mock_file_${Date.now()}`,
format: requestData?.format || 'jpg',
url: `https://mock-assets.local/files/${requestData?.filekey || 'mock_file'}`,
}, '保存成功 (mock)'),
},
{
action: 'upload',
method: 'POST',
handle: () => buildMockSuccess({
token: `mock_qiniu_token_${Date.now()}`,
domain: 'https://mock-assets.local',
upload_url: 'https://mock-assets.local/upload',
}, '上传 token 获取成功 (mock)'),
},
]
import { authMockHandlers } from './auth.mock'
import { paymentMockHandlers } from './payment.mock'
import { commonMockHandlers } from './common.mock'
import { messageMockHandlers } from './message.mock'
export const mockHandlers = [
...authMockHandlers,
...paymentMockHandlers,
...commonMockHandlers,
...messageMockHandlers,
]
import { getMessageDetail, getMessagePage } from '../stores/message.store'
import { buildMockError, buildMockSuccess } from '../shared/response'
export const messageMockHandlers = [
{
action: 'message',
type: 'list',
method: 'GET',
handle: ({ requestParams }) => buildMockSuccess(getMessagePage({
page: requestParams.page || 0,
limit: requestParams.limit || 6,
}), '消息列表获取成功 (mock)'),
},
{
action: 'message',
type: 'detail',
method: 'GET',
handle: ({ requestParams, requestData }) => {
const detail = getMessageDetail(requestParams.id || requestData.id)
if (!detail) {
return buildMockError('消息不存在 (mock)')
}
return buildMockSuccess(detail, '消息详情获取成功 (mock)')
},
},
]
import { buildMockSuccess } from '../shared/response'
const buildPaymentParams = (prefix, id) => {
const normalized = String(id || 'mock-id')
const seed = `${prefix}_${normalized}_${Date.now()}`
return {
timeStamp: `${Math.floor(Date.now() / 1000)}`,
nonceStr: `mock_nonce_${seed}`,
package: `prepay_id=${seed}`,
signType: 'MD5',
paySign: `mock_sign_${seed}`,
}
}
export const paymentMockHandlers = [
{
action: 'pay',
method: 'POST',
handle: ({ requestData }) => buildMockSuccess({
...buildPaymentParams('order', requestData?.order_id),
order_id: String(requestData?.order_id || 'mock_order_id'),
}, '支付参数获取成功 (mock)'),
},
{
action: 'icbc_pay_wxamp',
method: 'POST',
handle: ({ requestData }) => buildMockSuccess({
...buildPaymentParams('pay', requestData?.pay_id),
pay_id: String(requestData?.pay_id || 'mock_pay_id'),
}, '支付参数获取成功 (mock)'),
},
]
import qs from 'qs'
export const parseMockUrl = (input) => {
const raw = String(input || '')
const queryString = raw.includes('?') ? raw.slice(raw.indexOf('?') + 1) : ''
const query = qs.parse(queryString)
return {
raw,
action: String(query.a || ''),
type: String(query.t || ''),
query,
}
}
export const normalizeMockData = (data, headers = {}) => {
if (!data) return {}
if (typeof data === 'string') {
const contentType = String(headers?.['content-type'] || headers?.['Content-Type'] || '')
if (contentType.includes('application/x-www-form-urlencoded')) {
return qs.parse(data)
}
try {
return JSON.parse(data)
} catch (error) {
return { raw: data }
}
}
if (typeof data === 'object') {
return data
}
return { raw: data }
}
export const createMockRequestContext = ({ url, method = 'GET', data, params, headers }) => {
const urlParts = parseMockUrl(url)
return {
url: String(url || ''),
method: String(method || 'GET').toUpperCase(),
action: urlParts.action,
type: urlParts.type,
requestData: normalizeMockData(data, headers),
requestParams: {
...(urlParts.query || {}),
...(params || {}),
},
}
}
export const buildMockSuccess = (data, msg = 'success (mock)') => ({
code: 1,
msg,
data,
})
export const buildMockError = (msg = 'mock error', data = null, code = 0) => ({
code,
msg,
data,
})
export const buildMockFallbackPayload = ({ action, type, method, requestData, requestParams }) => ({
action,
type,
method,
echo: {
params: requestParams,
data: requestData,
},
list: [],
detail: {
title: '请按真实接口结构替换这里的 mock 数据',
updated_at: new Date().toISOString(),
},
})
export const matchMockHandler = (handler, context) => {
const matchedMethod = !handler.method || String(handler.method).toUpperCase() === context.method
const matchedAction = !handler.action || String(handler.action) === context.action
const matchedType = !handler.type || String(handler.type) === context.type
return matchedMethod && matchedAction && matchedType
}
export const findMockHandler = (handlers, context) => (
handlers.find((handler) => matchMockHandler(handler, context)) || null
)
import { createMessageFixtureList } from '../fixtures/message.fixture'
let messageList = null
export const ensureMessageStore = () => {
if (!messageList) {
messageList = createMessageFixtureList()
}
return messageList
}
export const getMessagePage = ({ page = 0, limit = 6 } = {}) => {
const list = ensureMessageStore()
const start = Number(page) * Number(limit)
const pageList = list.slice(start, start + Number(limit)).map((item) => ({
id: item.id,
title: item.title,
summary: item.summary,
status: item.status,
category: item.category,
created_time: item.created_time,
}))
return {
list: pageList,
total: list.length,
page: Number(page),
limit: Number(limit),
has_more: start + Number(limit) < list.length,
}
}
export const getMessageDetail = (id) => {
const list = ensureMessageStore()
const current = list.find((item) => item.id === String(id || ''))
if (!current) {
return null
}
current.status = MESSAGE_STATUS.read
return { ...current }
}
export default {
navigationBarTitleText: '消息详情',
}
<template>
<view class="message-detail-page">
<view class="page-content">
<view class="hero-card">
<text class="hero-title">消息详情</text>
<text class="hero-desc">
进入详情页后,当前消息会在 mock 中自动标记为已读,返回列表即可看到状态变化。
</text>
</view>
<view v-if="loading" class="placeholder-card">
<text class="section-title">加载中</text>
<text class="section-desc">正在获取消息详情...</text>
</view>
<view v-else-if="detail" class="detail-card">
<view class="detail-meta">
<text class="detail-category">{{ detail.category }}</text>
<text class="detail-time">{{ detail.created_time }}</text>
</view>
<text class="detail-title">{{ detail.title }}</text>
<text class="detail-content">{{ detail.content }}</text>
</view>
<view v-else class="placeholder-card">
<text class="section-title">未找到消息</text>
<text class="section-desc">
当前消息不存在,或者真实接口还没有返回这条详情数据。
</text>
</view>
<button class="back-btn" @tap="goBack">返回消息列表</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import { messageDetailAPI } from '@/api/message'
const loading = ref(true)
const detail = ref(null)
const fetchMessageDetail = async (id) => {
loading.value = true
try {
const response = await messageDetailAPI({ id })
if (response?.code === 1) {
detail.value = response.data
return
}
detail.value = null
Taro.showToast({
title: response?.msg || '获取详情失败',
icon: 'none',
})
} catch (error) {
console.error('获取消息详情失败:', error)
detail.value = null
} finally {
loading.value = false
}
}
const goBack = () => {
Taro.navigateBack()
}
useLoad((options) => {
const id = String(options?.id || '').trim()
if (!id) {
loading.value = false
return
}
fetchMessageDetail(id)
})
</script>
<style lang="less">
.message-detail-page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(166, 121, 57, 0.16), transparent 30%),
linear-gradient(180deg, #fffaf4 0%, #f4f6fb 100%);
.page-content {
padding: 32rpx 24rpx 48rpx;
box-sizing: border-box;
}
.hero-card,
.detail-card,
.placeholder-card {
padding: 32rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.94);
border: 2rpx solid rgba(166, 121, 57, 0.08);
box-shadow: 0 20rpx 60rpx rgba(15, 23, 42, 0.06);
box-sizing: border-box;
}
.hero-title,
.section-title,
.detail-title {
display: block;
color: #111827;
}
.hero-title,
.detail-title {
font-size: 40rpx;
font-weight: 700;
}
.hero-desc,
.section-desc,
.detail-content {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.8;
color: #6b7280;
white-space: pre-wrap;
}
.detail-card,
.placeholder-card,
.back-btn {
margin-top: 24rpx;
}
.detail-meta {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 18rpx;
}
.detail-category {
padding: 8rpx 14rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #92400e;
background: #fef3c7;
}
.detail-time {
font-size: 22rpx;
color: #9ca3af;
}
.back-btn {
border-radius: 999rpx;
font-size: 28rpx;
line-height: 84rpx;
color: #0f172a;
background: #ffffff;
border: 2rpx solid #d1d5db;
}
}
</style>
......@@ -2,18 +2,70 @@
<view class="message-page">
<view class="page-content">
<view class="hero-card">
<text class="hero-title">消息</text>
<view class="hero-head">
<text class="hero-title">消息</text>
<text class="env-tag" :class="{ mock: current_env_use_mock }">
{{ current_env_label }}
</text>
</view>
<text class="hero-desc">
这里预留给系统通知、预约提醒和支付结果消息。当前先完成 Tab 栏结构,后续再接真实消息数据
当前环境由配置文件统一控制。本地如果要让所有接口都走 mock,只需要改 `config/dev.js` 里的 `API_RUNTIME_ENV`
</text>
</view>
<view class="placeholder-card">
<text class="section-title">当前状态</text>
<view class="toolbar-card">
<view>
<text class="section-title">列表状态</text>
<text class="section-desc">
共 {{ total }} 条消息,未读 {{ unread_count }} 条。进入详情页后,当前消息会在 mock 中自动变成已读。
</text>
</view>
<button class="refresh-btn" :loading="loading" @tap="handleRefresh">
刷新
</button>
</view>
<view v-if="loading && !message_list.length" class="placeholder-card">
<text class="section-title">加载中</text>
<text class="section-desc">
暂无消息内容,后续可在这里接接口列表、未读计数和消息详情跳转。
正在拉取消息列表...
</text>
</view>
<view v-else-if="!message_list.length" class="placeholder-card">
<text class="section-title">暂无消息</text>
<text class="section-desc">
当前接口还没有返回消息内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。
</text>
</view>
<view
v-for="item in message_list"
:key="item.id"
class="message-card"
@tap="goToDetail(item.id)"
>
<view class="message-top">
<view class="message-meta">
<text class="message-category">{{ item.category }}</text>
<text class="message-time">{{ item.created_time }}</text>
</view>
<text class="message-status" :class="{ unread: item.status === 'send' }">
{{ item.status === 'send' ? '未读' : '已读' }}
</text>
</view>
<text class="message-title">{{ item.title }}</text>
<text class="message-summary">{{ item.summary }}</text>
</view>
<button
v-if="has_more && message_list.length"
class="load-more-btn"
:loading="loading_more"
@tap="handleLoadMore"
>
加载更多
</button>
</view>
<AppTabbar current="message" />
......@@ -21,7 +73,86 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import AppTabbar from '@/components/AppTabbar.vue'
import { messageListAPI } from '@/api/message'
import { getCurrentApiConfig } from '@/utils/config'
const api_config = getCurrentApiConfig()
const current_env_label = api_config.label
const current_env_use_mock = api_config.useMock
const message_list = ref([])
const page = ref(0)
const page_size = 6
const total = ref(0)
const has_more = ref(true)
const loading = ref(false)
const loading_more = ref(false)
const has_loaded_once = ref(false)
const unread_count = computed(() => (
message_list.value.filter((item) => item.status === 'send').length
))
const fetchMessageList = async (nextPage = 0, append = false) => {
if (append) {
loading_more.value = true
} else {
loading.value = true
}
try {
const response = await messageListAPI({
page: nextPage,
limit: page_size,
})
if (response?.code !== 1) {
Taro.showToast({
title: response?.msg || '获取消息失败',
icon: 'none',
})
return
}
const list = response?.data?.list || []
message_list.value = append ? [...message_list.value, ...list] : list
total.value = Number(response?.data?.total || list.length)
has_more.value = !!response?.data?.has_more
page.value = nextPage
} catch (error) {
console.error('获取消息列表失败:', error)
} finally {
loading.value = false
loading_more.value = false
}
}
const handleRefresh = async () => {
await fetchMessageList(0, false)
}
const handleLoadMore = async () => {
if (!has_more.value || loading_more.value) return
await fetchMessageList(page.value + 1, true)
}
const goToDetail = (id) => {
Taro.navigateTo({
url: `/pages/message-detail/index?id=${encodeURIComponent(id)}`,
})
}
useLoad(async () => {
await fetchMessageList(0, false)
has_loaded_once.value = true
})
useDidShow(async () => {
if (!has_loaded_once.value) return
await fetchMessageList(0, false)
})
</script>
<style lang="less">
......@@ -37,6 +168,8 @@ import AppTabbar from '@/components/AppTabbar.vue'
}
.hero-card,
.toolbar-card,
.message-card,
.placeholder-card {
padding: 32rpx;
border-radius: 28rpx;
......@@ -46,6 +179,20 @@ import AppTabbar from '@/components/AppTabbar.vue'
box-sizing: border-box;
}
.hero-head,
.toolbar-card,
.message-top,
.message-meta {
display: flex;
align-items: center;
}
.hero-head,
.toolbar-card,
.message-top {
justify-content: space-between;
}
.hero-title,
.section-title {
display: block;
......@@ -67,8 +214,94 @@ import AppTabbar from '@/components/AppTabbar.vue'
margin-top: 24rpx;
}
.toolbar-card,
.message-card {
margin-top: 24rpx;
}
.section-title {
font-size: 30rpx;
}
.env-tag {
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #1d4ed8;
background: #dbeafe;
}
.env-tag.mock {
color: #166534;
background: #dcfce7;
}
.refresh-btn,
.load-more-btn {
border-radius: 999rpx;
font-size: 26rpx;
line-height: 80rpx;
}
.refresh-btn {
min-width: 180rpx;
color: #0f172a;
background: #fff;
border: 2rpx solid #d1d5db;
}
.message-card {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.message-meta {
gap: 14rpx;
}
.message-category,
.message-time,
.message-status {
font-size: 22rpx;
}
.message-category {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #92400e;
background: #fef3c7;
}
.message-time {
color: #9ca3af;
}
.message-status {
color: #94a3b8;
}
.message-status.unread {
color: #dc2626;
}
.message-title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
.message-summary {
font-size: 25rpx;
line-height: 1.7;
color: #6b7280;
}
.load-more-btn {
margin-top: 24rpx;
color: #0f172a;
background: #ffffff;
border: 2rpx solid #d1d5db;
}
}
</style>
......
import Taro from '@tarojs/taro'
import { routerStore } from '@/stores/router'
import { buildApiUrl } from './tools'
import { createTaroMockResponse, shouldUseMock } from '../mock'
// 改进:添加全局状态变量注释
/**
......@@ -124,11 +125,18 @@ export const refreshSession = async (options) => {
}
// 换取后端会话(服务端通过 Set-Cookie 返回会话信息)
const response = await Taro.request({
url: buildApiUrl('openid'),
method: 'POST',
data: request_data,
})
const auth_url = buildApiUrl('openid')
const response = shouldUseMock()
? await createTaroMockResponse({
url: auth_url,
method: 'POST',
data: request_data,
})
: await Taro.request({
url: auth_url,
method: 'POST',
data: request_data,
})
if (!response?.data || response.data.code !== 1) {
throw new Error(response?.data?.msg || '授权失败')
......
/*
* @Description: 服务器环境配置
* @Note: 对齐 meihuaApp 的最小授权/支付测试环境
* @Description: API 环境配置
* @Note: 当前环境只由构建配置控制;本地开发默认 mock,生产构建默认正式环境
*/
/**
* @description 接口基础域名
* - 授权与支付测试均复用 meihuaApp 所在后端
* @type {string}
*/
const BASE_URL = 'https://oa.onwall.cn'
export const API_ENVIRONMENTS = {
production: {
key: 'production',
label: '正式环境',
baseURL: 'https://oa.onwall.cn',
requestDefaultParams: {
f: 'room',
client_id: '772428',
},
useMock: false,
},
mock: {
key: 'mock',
label: '本地 Mock 环境',
baseURL: 'https://oa.onwall.cn',
requestDefaultParams: {
f: 'room',
client_id: '772428',
},
useMock: true,
},
}
/**
* @description 接口默认公共参数
* - f/client_id 与 meihuaApp 保持一致,确保 openid 与 pay 接口可用
*/
export const REQUEST_DEFAULT_PARAMS = {
f: 'room',
client_id: '772428',
export const DEFAULT_API_ENV = process.env.NODE_ENV === 'production' ? 'production' : 'mock'
export const getApiEnv = () => {
const runtimeEnv = process.env.API_RUNTIME_ENV
if (runtimeEnv && API_ENVIRONMENTS[runtimeEnv]) {
return runtimeEnv
}
return DEFAULT_API_ENV
}
export const getCurrentApiConfig = () => {
const env = getApiEnv()
return API_ENVIRONMENTS[env] || API_ENVIRONMENTS[DEFAULT_API_ENV]
}
export const getBaseUrl = () => getCurrentApiConfig().baseURL
export const getRequestDefaultParams = () => ({ ...getCurrentApiConfig().requestDefaultParams })
export const isMockEnabled = () => !!getCurrentApiConfig().useMock
export const getApiEnvLabel = () => getCurrentApiConfig().label
const BASE_URL = getBaseUrl()
export const REQUEST_DEFAULT_PARAMS = getRequestDefaultParams()
export default BASE_URL
......
......@@ -17,7 +17,7 @@ import { parseQueryString } from './tools'
// import { ProgressStart, ProgressEnd } from '@/components/axios-progress/progress';
// import store from '@/store'
// import { getToken } from '@/utils/auth'
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config';
import { getBaseUrl, getRequestDefaultParams } from './config';
/**
* @description 获取 sessionid 的工具函数
......@@ -73,7 +73,7 @@ export const clearSessionId = () => {
* - 通过拦截器处理:默认参数、cookie 注入、401 自动续期、弱网降级
*/
const service = axios.create({
baseURL: BASE_URL, // url = base url + request url
baseURL: getBaseUrl(), // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 5000, // request timeout
})
......@@ -153,6 +153,8 @@ const handle_request_timeout = async () => {
// 请求拦截器:合并默认参数 / 注入 cookie
service.interceptors.request.use(
config => {
config.baseURL = getBaseUrl()
// console.warn(config)
// console.warn(store)
......@@ -166,7 +168,7 @@ service.interceptors.request.use(
// 优先级:调用传参 > URL参数 > 默认参数
config.params = {
...REQUEST_DEFAULT_PARAMS,
...getRequestDefaultParams(),
...url_params,
...(config.params || {})
}
......
......@@ -7,7 +7,7 @@
*/
import dayjs from 'dayjs';
import Taro from '@tarojs/taro';
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'
import { getBaseUrl, getRequestDefaultParams } from './config'
/**
* @description 格式化时间
......@@ -191,11 +191,11 @@ const get_bill_status_text = (status) => {
const buildApiUrl = (action, params = {}) => {
const base_params = {
a: action,
...REQUEST_DEFAULT_PARAMS,
...getRequestDefaultParams(),
...params,
}
const queryParams = new URLSearchParams(base_params)
return `${BASE_URL}/srv/?${queryParams.toString()}`
return `${getBaseUrl()}/srv/?${queryParams.toString()}`
}
export { formatDate, wxInfo, parseQueryString, strExist, formatDatetime, mask_id_number, get_qrcode_status_text, get_bill_status_text, buildApiUrl };
......