609 lines
16 KiB
Markdown
609 lines
16 KiB
Markdown
# warehouse 客户端代码深度审核报告(含完整修复方案)
|
||
|
||
> 日期: 2026-06-04 | 项目: warehouse/ | 扫描: 38 源文件, ~12,000 行 | 问题: 70 项
|
||
|
||
---
|
||
|
||
## 1. `api/http.js` — 9 项
|
||
|
||
### H1 [🔴] `lang_storage_key` 未定义
|
||
|
||
**位置**: 第 134 行
|
||
```javascript
|
||
function setHeaderLang(_header) {
|
||
let langType = localStorage.getItem(lang_storage_key) // ← 未定义!
|
||
}
|
||
```
|
||
**后果**: 运行时报错 `lang_storage_key is not defined`,语言功能完全失效。
|
||
**修复** — 在函数上方加常量:
|
||
```javascript
|
||
const lang_storage_key = 'lang'
|
||
```
|
||
|
||
### H2 [🔴] `replaceToken` 未定义
|
||
|
||
**位置**: 第 107 行
|
||
```javascript
|
||
function checkResponse(res) { if (res.headers.vol_exp == '1') { replaceToken() } }
|
||
```
|
||
**后果**: Token 过期后调用未定义函数,静默失败。
|
||
**修复** — 在 `checkResponse` 前追加:
|
||
```javascript
|
||
function replaceToken() {
|
||
store.dispatch('clearUserInfo')
|
||
window.location.href = '/login'
|
||
}
|
||
```
|
||
**跨文件影响**: 需确认 `store/index.js` 有 `clearUserInfo` mutation(已存在)。
|
||
|
||
### H3 [🔴] `toLogin` 未定义
|
||
|
||
**位置**: 第 77 行
|
||
```javascript
|
||
if (error.response.status == '401') { toLogin() }
|
||
```
|
||
**修复** — 在文件顶部 import router 后追加:
|
||
```javascript
|
||
import router from '@/router'
|
||
function toLogin() { router.push('/login') }
|
||
```
|
||
**跨文件影响**: 需确认 `router/index.ts` 导出 router 实例。
|
||
|
||
### H4 [🟠] 降级地址硬编码
|
||
|
||
**位置**: 第 25-33 行
|
||
```javascript
|
||
axios.defaults.baseURL = 'http://192.168.3.108:9100/'
|
||
dataViewUrl = 'http://192.168.3.108:9200/'
|
||
```
|
||
**修复**:
|
||
```javascript
|
||
axios.defaults.baseURL = window.location.origin
|
||
dataViewUrl = (window as any).apiConfig?.dataViewUrl || window.location.origin
|
||
```
|
||
|
||
### H5 [🟠] `get()` 参数 `param` 未使用
|
||
|
||
**位置**: 第 176 行
|
||
```javascript
|
||
function get(url, param, loading, config) { axios.get(url, config) }
|
||
```
|
||
**修复**:
|
||
```javascript
|
||
function get(url, param, loading, config) {
|
||
const cfg = { ...config }
|
||
if (param) cfg.params = param
|
||
// ... 其余不变
|
||
axios.get(url, cfg).then(...)
|
||
}
|
||
```
|
||
|
||
### H6 [🟡] `closeLoading` 冗余
|
||
|
||
**位置**: 第 92-101 行
|
||
```javascript
|
||
if (loadingInstance) loadingInstance.close()
|
||
if (loadingStatus) { loadingStatus = false; if (loadingInstance) loadingInstance.close() }
|
||
```
|
||
**修复**:
|
||
```javascript
|
||
loadingStatus = false
|
||
loadingInstance?.close()
|
||
```
|
||
|
||
### H7 [🟡] `alert()` 弹窗
|
||
|
||
**位置**: 第 199 行
|
||
```javascript
|
||
alert('http.js未配置大屏url地址')
|
||
```
|
||
**修复**:
|
||
```javascript
|
||
import { ElMessage } from 'element-plus'
|
||
ElMessage.error('未配置大屏URL地址')
|
||
```
|
||
|
||
### H8 [🟡] 无类型安全
|
||
|
||
**修复** — 在文件头部加 JSDoc(不改变运行时):
|
||
```typescript
|
||
/**
|
||
* @template T
|
||
* @param {string} url
|
||
* @param {object} [params]
|
||
* @param {boolean|string} [loading]
|
||
* @param {object} [config]
|
||
* @returns {Promise<T>}
|
||
*/
|
||
function post(url, params, loading, config) { ... }
|
||
```
|
||
|
||
### H9 [⚪] 文件臃肿 (409行)
|
||
|
||
**修复** — 拆为 3 文件:
|
||
- `api/http-client.ts` — Axios 实例 + baseURL + 拦截器 (80行)
|
||
- `api/http-auth.ts` — getToken/replaceToken/toLogin (40行)
|
||
- `api/http-loading.ts` — showLoading/closeLoading (20行)
|
||
|
||
`api/http.js` 改为:
|
||
```javascript
|
||
import { createHttpClient } from './http-client'
|
||
export default createHttpClient()
|
||
```
|
||
|
||
---
|
||
|
||
## 2. `api/gateway.ts` — 3 项
|
||
|
||
### GW1 [🟠] 网关地址硬编码
|
||
|
||
**位置**: 第 5 行 `const GW_BASE = 'http://192.168.3.108:5100'`
|
||
|
||
**修复**:
|
||
```typescript
|
||
const GW_BASE = (window as any).apiConfig?.gatewayUrl || 'http://localhost:5100'
|
||
```
|
||
|
||
### GW2 [🟡] `fetch()` 无超时
|
||
|
||
**修复** — 完整重写 `gwGet`(`gwPost` 同理):
|
||
```typescript
|
||
export async function gwGet(url: string, timeoutMs = 10000): Promise<any> {
|
||
const ctrl = new AbortController()
|
||
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||
try {
|
||
const resp = await fetch(`${GW_BASE}${url}`, { signal: ctrl.signal })
|
||
if (!resp.ok) throw new Error(`网关请求失败: ${resp.status}`)
|
||
return resp.json()
|
||
} finally { clearTimeout(timer) }
|
||
}
|
||
```
|
||
|
||
### GW3 [🟡] 模型映射混入API层
|
||
|
||
**修复** — 新建 `warehouse/src/services/cameraService.ts`:
|
||
```typescript
|
||
import { gwGet, type Camera, type StandardDevice } from '@/api/gateway'
|
||
|
||
export function toCamera(d: StandardDevice): Camera { ... }
|
||
export async function fetchCameras(adapter: string): Promise<Camera[]> { ... }
|
||
```
|
||
**跨文件影响**: `Live.vue`, `VideoWall.vue`, `History.vue` 的 import 改为 `from '@/services/cameraService'`
|
||
|
||
---
|
||
|
||
## 3. `api/buttons.js` — 1 项
|
||
|
||
### B1 [🟡] Element UI 旧版图标语法
|
||
|
||
```javascript
|
||
icon: 'el-icon-search'
|
||
```
|
||
Element Plus ≥2.0 已废弃字符串图标。
|
||
|
||
**修复** — 改为组件引用:
|
||
```javascript
|
||
import { Search, Plus, Edit, DocumentCopy, Delete, Check, Finished, Top, Bottom, Printer } from '@element-plus/icons-vue'
|
||
import { shallowRef } from 'vue'
|
||
|
||
const buttons = [
|
||
{ name:'查询', icon: shallowRef(Search), ... },
|
||
{ name:'新建', icon: shallowRef(Plus), ... },
|
||
// ... 其余同理
|
||
]
|
||
```
|
||
**跨文件影响**: 使用 buttons 的组件需确认其渲染逻辑支持组件引用(通常通过 `<component :is="btn.icon" />`)。
|
||
|
||
---
|
||
|
||
## 4. `api/permission.js` — 2 项
|
||
|
||
### PE1 [🟠] 权限缺失时静默放行
|
||
|
||
**位置**: 第 24 行
|
||
```javascript
|
||
if (!permission) { permission = { permission: ['Search'] } }
|
||
```
|
||
**修复**:
|
||
```javascript
|
||
if (!permission) { return [] }
|
||
```
|
||
空数组使所有按钮不可见(安全默认)。
|
||
|
||
### PE2 [🟡] `to401` 空实现
|
||
|
||
**修复**:
|
||
```javascript
|
||
import router from '@/router'
|
||
function to401() { router.push('/401') }
|
||
```
|
||
|
||
---
|
||
|
||
## 5. `router/index.ts` — 5 项
|
||
|
||
### R1 [🟠] 40+ 条路由指向同一组件
|
||
|
||
所有菜单子项渲染 `Index.vue`,用户看到重复空白页。
|
||
|
||
**修复** — 3 步方案:
|
||
1. 为已实现的页面保留独立路由(VideoWall/AlarmRecord/AccessRecord 等已存在)
|
||
2. 其余指向一个占位组件:
|
||
```typescript
|
||
{ path: "/index/goods/list", component: () => import("@/view/Placeholder.vue") }
|
||
```
|
||
3. `Placeholder.vue`:
|
||
```vue
|
||
<template><el-empty description="功能开发中" /></template>
|
||
```
|
||
|
||
### R2 [🟡] `/new-dv` 不要求认证
|
||
|
||
```typescript
|
||
{ path:"/new-dv", meta:{ requiresAuth: false } }
|
||
```
|
||
**修复**: 改为 `requiresAuth: true`。
|
||
|
||
### R3 [🟡] 缺少 beforeEach 守卫
|
||
|
||
**修复** — 在 `router/index.ts` 末尾追加:
|
||
```typescript
|
||
router.beforeEach((to, from, next) => {
|
||
if (to.meta.requiresAuth !== false) {
|
||
const token = localStorage.getItem('token')
|
||
if (token) next()
|
||
else next('/login')
|
||
} else { next() }
|
||
})
|
||
```
|
||
|
||
### R4 [⚪] `@ts-ignore` 绕过 store 类型
|
||
|
||
**修复**: 创建 `warehouse/src/types/store.d.ts`:
|
||
```typescript
|
||
declare module '@/store' {
|
||
const store: import('vuex').Store<any>
|
||
export default store
|
||
}
|
||
```
|
||
|
||
### R5 [⚪] 仓库/货物/出入库路由 20+ 条未使用
|
||
|
||
**修复**: 删除。若后续需要可从 git 恢复。
|
||
|
||
---
|
||
|
||
## 6. `main.ts` — 1 项
|
||
|
||
### M1 [🟡] 暗色模式写死
|
||
|
||
```typescript
|
||
app.use(ElementPlus, { dark: true })
|
||
```
|
||
**修复**:
|
||
```typescript
|
||
const dark = localStorage.getItem('dark-mode') !== 'false'
|
||
app.use(ElementPlus, { dark })
|
||
```
|
||
|
||
---
|
||
|
||
## 7. `view/index.js` — 6 项
|
||
|
||
### SI1 [🔴] `displayedMessageIds` Set 无限增长
|
||
|
||
**位置**: 第 8 行 `let displayedMessageIds = new Set()`
|
||
**后果**: 运行数天后 Set 包含成千上万条 ID → 内存泄漏。
|
||
|
||
**修复** — 改为 LRU 缓存(保留最近 500 条):
|
||
```javascript
|
||
class LruSet {
|
||
#set = new Set()
|
||
#max = 500
|
||
add(v) { if (this.#set.has(v)) return; this.#set.add(v); if (this.#set.size > this.#max) { this.#set.delete(this.#set.values().next().value) } }
|
||
has(v) { return this.#set.has(v) }
|
||
}
|
||
const displayedMessageIds = new LruSet()
|
||
```
|
||
|
||
### SI2 [🟠] 消息队列 3s 延迟堆积
|
||
|
||
**修复**: 删除 `messageQueue`/`processMessageQueue`,直接调 `receive`:
|
||
```javascript
|
||
connection.on("ReceiveHomePageMessage", function (data) {
|
||
if (displayedMessageIds.has(data.id)) return
|
||
displayedMessageIds.add(data.id)
|
||
receive(data)
|
||
})
|
||
```
|
||
|
||
### SI3 [🟠] connection 启动无重试
|
||
|
||
`connection.start().catch(...)` 失败后永远不重试。
|
||
|
||
**修复**:
|
||
```javascript
|
||
async function startWithRetry(retries = 5) {
|
||
for (let i = 0; i < retries; i++) {
|
||
try { await connection.start(); return }
|
||
catch (e) { console.warn(`SignalR retry ${i+1}/${retries}: ${e.message}`); await new Promise(r => setTimeout(r, 2000)) }
|
||
}
|
||
console.error('SignalR connection failed after retries')
|
||
}
|
||
startWithRetry()
|
||
```
|
||
|
||
### SI4 [🟡] `console.log` 残留 6 处
|
||
|
||
**修复**: 全部替换为 `if (import.meta.env.DEV) console.log(...)`。
|
||
|
||
### SI5 [🟡] `receive` 被双重调用
|
||
|
||
`processMessageQueue` 和 `ReceiveHomePageMessage` 都调了 `receive`。
|
||
|
||
**修复**: SI2 删除了 `processMessageQueue` 后此问题自动消除。
|
||
|
||
### SI6 [🟡] 用户信息获取失败静默
|
||
|
||
**修复**:
|
||
```javascript
|
||
http.post("api/user/GetCurrentUserInfo").then(...).catch(error => {
|
||
console.error('获取用户信息失败,SignalR未启动:', error)
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 8-28: 视图层文件(21 个 Vue 文件)
|
||
|
||
### 通用修复模式(适用于所有 Mock 页面)
|
||
|
||
以下 17 个页面全 Mock —— 统一修复方案:
|
||
|
||
```
|
||
AccessRecord.vue, AlarmRecord.vue, EmergencyAlarmRecord.vue,
|
||
KeyInfo.vue, KeyApply.vue, EnvVarManagement.vue,
|
||
PatrolLog.vue, ScheduleManagement.vue, PathManagement.vue,
|
||
DroneManagement.vue, dataview.vue, CarApply.vue, CarManager.vue,
|
||
DeviceStatus.vue(V), VisitorsManagement.vue, VisitCarManagement.vue
|
||
```
|
||
|
||
**统一修复**: 每个页面增加网关 API 调用骨架,Mock 数据降级为 fallback:
|
||
|
||
```typescript
|
||
// 以 AlarmRecord.vue 为例
|
||
import { gwGet } from '@/api/gateway'
|
||
|
||
const fetchData = async () => {
|
||
try {
|
||
const data = await gwGet('/api/gateway/alarms/Owl:main?page=1&size=100')
|
||
alarmData.value = data.items.map((a: StandardAlarm) => ({
|
||
id: a.alarmId, alarmTime: a.occurTime, deviceName: a.title,
|
||
location: a.deviceId, status: a.status, imageUrl: '/images/placeholder.png'
|
||
}))
|
||
} catch {
|
||
alarmData.value = getMockAlarmData() // fallback
|
||
}
|
||
}
|
||
```
|
||
|
||
### 单个页面专项修复
|
||
|
||
**DataView.vue** (1840行):
|
||
- DV1: 告警等级 → 改用 `level` 字段或网关 `StandardAlarm.level` 值
|
||
- DV2: `setTimeout` 加 `clearTimeout`:
|
||
```typescript
|
||
const timers = new Set<number>()
|
||
onBeforeUnmount(() => timers.forEach(t => clearTimeout(t)))
|
||
// 使用时: timers.add(setTimeout(...))
|
||
```
|
||
- DV6: `originalData: JSON.parse(JSON.stringify(data))` 避免循环引用
|
||
|
||
**DeviceInfo.vue** (1300行):
|
||
- DI1: 对接网关 B4 获取真实在线率
|
||
- DI2: `randomVideoImage` → `gwGet('/api/gateway/streams/.../live')`
|
||
- DI3: `handleTurnOn` → `gwPost('/api/gateway/realtime/.../control', { deviceId, pointIndex, value })`
|
||
|
||
**Live.vue** / **VideoWall.vue** / **History.vue**:
|
||
- LV2/VH2: `setInterval` 加清理:
|
||
```typescript
|
||
const timer = setInterval(updateTime, 1000)
|
||
onBeforeUnmount(() => clearInterval(timer))
|
||
```
|
||
|
||
**Main.vue**:
|
||
- MA1: icon 改为 `@element-plus/icons-vue` 组件引用
|
||
- MA2: Menu 配置提取:
|
||
```typescript
|
||
const menuItems = [
|
||
{ index:'1', icon: VideoCamera, label:'视频监控', children:[
|
||
{ index:'/index/video/videowall', label:'视频墙' }
|
||
]}
|
||
]
|
||
```
|
||
- MA5: 统一 `import { useMapStore } from '@/stores/mapStore'`
|
||
|
||
**Map.vue**:
|
||
- MP1: `const m = /#\/(\d+)/.exec(location.hash); const mapId = m?.[1] || 'default'`
|
||
|
||
**Index.vue**:
|
||
- IN1: `const mapId = import.meta.env.VITE_MAP_ID || 'default'`
|
||
|
||
---
|
||
|
||
## 29-31: 组件层(3 文件)
|
||
|
||
### Filter.vue [🟡]
|
||
|
||
**console.log 8 处**: 同 SI4,改为条件输出。
|
||
|
||
**硬件设备图标映射** 提取为常量:
|
||
```typescript
|
||
const DEVICE_ICONS: Record<string, string> = {
|
||
'摄像头':'/images/dataview/deviceinfo/camera.png',
|
||
'门禁':'/images/dataview/deviceinfo/access.png',
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### Fence.vue [🟡]
|
||
|
||
**硬编码仓库名**: `['1号库','2号库','12号库']`
|
||
**修复**: 从 store 或配置注入:
|
||
```typescript
|
||
const warehouseNames = inject('warehouseNames', ['1号库'])
|
||
const inFencePoints = store.polygonDataAll.filter(p => warehouseNames.includes(p.name))
|
||
```
|
||
|
||
---
|
||
|
||
## 32-34: 状态管理层(3 文件)
|
||
|
||
### SM1 [🔴] 两份 `useMapStore` 同名
|
||
|
||
**文件**: `stores/mapStore.js` 和 `store/useMapStore.js` 都 `defineStore('map', ...)`
|
||
|
||
**修复** — 选择一份保留(推荐 `stores/mapStore.js` 功能更全),另一份删除:
|
||
```bash
|
||
git rm warehouse/src/store/useMapStore.js
|
||
```
|
||
**跨文件影响** — 修改以下文件的 import:
|
||
- `Map.vue` line 9: `'../store/useMapStore'` → `'../stores/mapStore'`
|
||
- `Index.vue` line 10: `'../stores/mapStore'` (已对)
|
||
- `Fence.vue` line 4: `'../store/useMapStore'` → `'../stores/mapStore'`
|
||
- `Filter.vue` line 3: `'../store/useMapStore'` → `'../stores/mapStore'`
|
||
- `DataView.vue` line 10: `'../stores/mapStore'` (已对)
|
||
|
||
### ST1 [🟠] `getServiceList` getter 忽略参数
|
||
|
||
**位置**: `store/index.js`
|
||
```javascript
|
||
getServiceList: (state) => (path) => { return state.serviceList || [] }
|
||
```
|
||
**修复**: 如果不需要按 path 过滤则简化为:
|
||
```javascript
|
||
getServiceList: (state) => state.serviceList || []
|
||
```
|
||
|
||
### ST3 [🟡] `test` mutation 返回 `113344`
|
||
|
||
**位置**: `store/index.js` line 49
|
||
```javascript
|
||
test(state) { return 113344 } // 调试代码
|
||
```
|
||
**修复**: 删除此 mutation。
|
||
|
||
### ST4 [⚪] `setPermission` 数组 push 会叠加
|
||
|
||
```javascript
|
||
if (data instanceof Array) { state.permission.push(...data) }
|
||
```
|
||
每次调用追加而非替换。**修复**: 始终替换 `state.permission = data`。
|
||
|
||
---
|
||
|
||
## 35: 项目结构 — 4 项
|
||
|
||
### PS1/PS2 [🟡] 过期副本文件
|
||
|
||
```bash
|
||
git rm warehouse/src/view/DataView\ copy.vue
|
||
git rm warehouse/src/view/Map.vue.bak
|
||
```
|
||
|
||
### PS3 [🟡] 文档放在源码目录
|
||
```bash
|
||
mkdir -p warehouse/doc
|
||
mv warehouse/src/view/intercom/TODO_*.md warehouse/doc/
|
||
```
|
||
|
||
### PS4 [🟠] Vuex + Pinia 共存
|
||
|
||
**修复** — 迁移 Vuex → Pinia:
|
||
1. 新建 `stores/authStore.js`(替代 Vuex 的 userInfo/token/permission)
|
||
2. 迁移 `store/index.js` 中的 `setUserInfo`/`getToken`/`getPermission` 到 Pinia
|
||
3. `npm uninstall vuex`
|
||
4. 修改 `http.js`/`Login.vue`/`permission.js` 从 `@/stores/authStore` 导入
|
||
|
||
---
|
||
|
||
## 36: 全局问题 — 7 项
|
||
|
||
### G1 [🟠] Mock 覆盖率 86%
|
||
|
||
22 页面中仅 3 个对接网关。**分 4 阶段对接**:
|
||
|
||
| 阶段 | 页面 | 网关接口 | 预计 |
|
||
|:--:|------|------|:--:|
|
||
| 1 | 告警页 (AlarmRecord/EmergencyAlarm) | B8 (GET /alarms) | 2h |
|
||
| 2 | 环境变量 (EnvVarManagement) | B4 (GET /realtime) | 2h |
|
||
| 3 | 门禁/钥匙 (AccessRecord/KeyInfo) | B2 (GET /devices) + B11 (GET /logs) | 3h |
|
||
| 4 | 巡更/无人机/车辆/访客 | B2 设备列表 | 1h |
|
||
|
||
### G2 [🟡] 硬编码IP散布6+文件
|
||
|
||
**修复** — 创建 `.env.development`:
|
||
```
|
||
VITE_GATEWAY_URL=http://localhost:5100
|
||
VITE_VOLPRO_URL=http://localhost:9100
|
||
```
|
||
各文件改为:
|
||
```typescript
|
||
const GW_BASE = import.meta.env.VITE_GATEWAY_URL || 'http://localhost:5100'
|
||
```
|
||
|
||
### G3 [🟡] JS/TS 混用
|
||
|
||
**修复**: 7 个 `.js` → `.ts`(逐文件迁移,不改运行时逻辑):
|
||
```
|
||
api/http.js → api/http.ts
|
||
api/buttons.js → api/buttons.ts
|
||
api/permission.js → api/permission.ts
|
||
view/index.js → view/index.ts
|
||
stores/mapStore.js → stores/mapStore.ts
|
||
router/viewGird.js → router/viewGird.ts
|
||
```
|
||
|
||
### G4 [🟡] `window.*` 全局变量
|
||
|
||
**修复**: `window.$map` → Store 管理(已存在 `mapStore.setMap`)
|
||
```typescript
|
||
// Map.vue: 删除 window.$map = map,保留 store.setMap(map)
|
||
// 其他文件: 改 window.$map → const { map } = useMapStore()
|
||
```
|
||
|
||
### G5 [🟡] `console.log` 残留 30+ 处
|
||
|
||
**修复** — 一行全局替换:
|
||
```bash
|
||
# 将所有 console.log 改为条件输出
|
||
find warehouse/src -name "*.vue" -o -name "*.ts" -o -name "*.js" | xargs sed -i 's/console\.log(/if(import\.meta\.env\.DEV)console.log(/g'
|
||
```
|
||
|
||
### G6 [⚪] 无全局错误边界
|
||
|
||
**修复** — `main.ts`:
|
||
```typescript
|
||
app.config.errorHandler = (err) => {
|
||
console.error('Global error:', err)
|
||
ElMessage.error('系统异常,请刷新页面')
|
||
}
|
||
```
|
||
|
||
### G7 [⚪] `counter.ts` 模板残留
|
||
|
||
**修复**: 删除 `warehouse/src/stores/counter.ts`。
|
||
|
||
---
|
||
|
||
## 执行优先级
|
||
|
||
| 批次 | 项 | 文件 | 预计 | 影响 |
|
||
|:--:|------|------|:--:|------|
|
||
| 🔴P0 | H1+H2+H3+SI1+SM1 | 5 | 2h | 修复运行时错误 |
|
||
| 🟠P1 | SI3+PE1+R1+R3+G1(告警) | 6 | 4h | 功能可用性 |
|
||
| 🟡P2 | GW1+GW2+H5+H6+DV1+LV2 | 6 | 3h | 代码质量 |
|
||
| ⚪P3 | PS+console+G6+CT1 | 5 | 1h | 整洁性 |
|
||
|
||
> **总计**: 70 项 / 预估 10h。P0 批 5 项必须在联调前完成。
|