Files
zkxw/docs/superpowers/plans/2026-05-15-operations-support.md
T
2026-05-17 08:36:37 +08:00

619 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 运营支撑模块 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement 3 operations support modules (洁净室管理, 库位管理, 展馆预约) with backend API + frontend UI, following existing codebase patterns exactly.
**Architecture:** FastAPI backend with flat-function CRUD (no service layer), Vue 3 + Element Plus frontend with standard list/tree page templates. All 3 modules share a new API route file `app/api/operations.py` and frontend route group.
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2 (backend); Vue 3 / TypeScript / Element Plus / Tailwind CSS 4 (frontend)
**Design Spec:** `docs/superpowers/specs/2026-05-15-operations-support-design.md`
**Key Reference Files (READ FIRST):**
- Backend CRUD pattern: `backend/app/crud/item_unit.py`
- Backend API pattern: `backend/app/api/basic_info.py` (lines 1-100 for simple CRUD)
- Backend model pattern: `backend/app/models/models.py` (class BizItemUnit, lines 259-275)
- Frontend standard list: `web-admin/src/views/system/unit/index.vue`
- Frontend tree template: `web-admin/src/views/system/category/index.vue`
- Frontend API pattern: `web-admin/src/api/admin.ts` (any CRUD section)
- Frontend router: `web-admin/src/router/modules/sqxp.ts`
---
## File Structure
### Backend (create/modify)
| File | Action | Purpose |
|------|--------|---------|
| `backend/app/models/models.py` | MODIFY | Add 5 model classes at end of file |
| `backend/app/crud/clean_room.py` | CREATE | CleanRoom + AccessLog CRUD functions |
| `backend/app/crud/storage_facility.py` | CREATE | StorageFacility tree + Slot CRUD functions |
| `backend/app/crud/exhibition_appointment.py` | CREATE | ExhibitionAppointment CRUD functions |
| `backend/app/api/operations.py` | CREATE | All 3 modules API routes |
| `backend/app/api/__init__.py` | MODIFY | Import + export operations_router |
| `backend/app/main.py` | MODIFY | Import + register operations_router |
| `backend/tests/test_operations_api.py` | CREATE | Backend API tests |
### Frontend (create/modify)
| File | Action | Purpose |
|------|--------|---------|
| `web-admin/src/api/admin.ts` | MODIFY | Add API functions for all 3 modules |
| `web-admin/src/router/modules/sqxp.ts` | MODIFY | Add 运营支撑 route group |
| `web-admin/src/views/operations/clean-room/index.vue` | CREATE | Clean room list page |
| `web-admin/src/views/operations/storage-facility/index.vue` | CREATE | Storage facility tree page |
| `web-admin/src/views/operations/exhibition-appointment/index.vue` | CREATE | Exhibition appointment list page |
---
## Task 1: Backend Models (5 SQLAlchemy models)
**Files:**
- Modify: `backend/app/models/models.py` (append at end, before EOF)
- [ ] **Step 1: Add 5 model classes to models.py**
Append the following after the last class in `backend/app/models/models.py`:
```python
# =============================================================================
# V0.4 运营支撑(2.4 洁净室管理 / 库位管理 / 展馆预约)
# =============================================================================
class BizCleanRoom(Base):
"""洁净室信息 biz_clean_room"""
__tablename__ = "biz_clean_room"
id = Column(String(64), primary_key=True, comment="主键ID")
room_code = Column(String(64), nullable=False, comment="编号")
room_name = Column(String(200), nullable=False, comment="名称")
room_grade = Column(String(8), nullable=True, comment="等级 A/B/C/D")
room_status = Column(String(16), nullable=False, default="zc", comment="zc正常 yc异常")
area_sqm = Column(Numeric(12, 2), nullable=True, comment="面积m²")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCleanRoomAccessLog(Base):
"""洁净室人员进出 biz_clean_room_access_log"""
__tablename__ = "biz_clean_room_access_log"
id = Column(String(64), primary_key=True, comment="主键ID")
clean_room_id = Column(String(64), nullable=False, comment="洁净室ID")
user_id = Column(String(64), nullable=True, comment="人员用户ID")
person_name = Column(String(100), nullable=True, comment="人员姓名(冗余)")
dept_name = Column(String(200), nullable=True, comment="部门")
enter_time = Column(DateTime, nullable=False, comment="进入时间")
purpose = Column(String(512), nullable=True, comment="目的")
leave_time = Column(DateTime, nullable=True, comment="离开时间")
body_temp = Column(String(32), nullable=True, comment="体温")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageFacility(Base):
"""细胞存储设施树节点 biz_storage_facility"""
__tablename__ = "biz_storage_facility"
id = Column(String(64), primary_key=True, comment="主键ID")
parent_id = Column(String(64), nullable=False, default="0", comment="父节点ID,0根")
node_name = Column(String(200), nullable=False, comment="节点名称(罐/层/盒等)")
node_type = Column(String(32), nullable=True, comment="facility/layer/box 等扩展")
is_leaf = Column(SmallInteger, nullable=False, default=0, comment="是否叶子存储位 1是")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageFacilitySlot(Base):
"""细胞存储设施储存位置(格子)biz_storage_facility_slot"""
__tablename__ = "biz_storage_facility_slot"
id = Column(String(64), primary_key=True, comment="主键ID")
facility_node_id = Column(String(64), nullable=False, comment="设施树叶子节点ID")
slot_code = Column(String(64), nullable=True, comment="小格编码/坐标")
slot_status = Column(String(16), nullable=True, comment="占用状态等扩展")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizExhibitionAppointment(Base):
"""展馆预约 biz_exhibition_appointment"""
__tablename__ = "biz_exhibition_appointment"
id = Column(String(64), primary_key=True, comment="主键ID")
appointment_no = Column(String(64), nullable=False, comment="预约编号")
visit_org = Column(String(200), nullable=True, comment="来访单位")
contact_name = Column(String(100), nullable=True, comment="联系人")
visit_date = Column(CHAR(8), nullable=True, comment="到访日期YYYYMMDD")
visitor_count = Column(Integer, nullable=True, comment="人数")
reception_user_id = Column(String(64), nullable=True, comment="接待人员用户ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
```
- [ ] **Step 2: Verify models load without errors**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.models.models import BizCleanRoom, BizCleanRoomAccessLog, BizStorageFacility, BizStorageFacilitySlot, BizExhibitionAppointment; print('All models imported OK')"`
Expected: "All models imported OK"
---
## Task 2: Backend CRUD — Exhibition Appointment (simplest, start here)
**Files:**
- Create: `backend/app/crud/exhibition_appointment.py`
- [ ] **Step 1: Create CRUD file**
Create `backend/app/crud/exhibition_appointment.py` following the `item_unit.py` pattern exactly. Functions: `_row_to_dict`, `list_exhibition_appointments`, `get_exhibition_appointment`, `appointment_no_exists`, `create_exhibition_appointment`, `update_exhibition_appointment`, `soft_delete_exhibition_appointment`, `total_pages`. All queries filter by `del_flag==0` and `tenant_no`. List supports keyword search on `appointment_no`, `visit_org`, `contact_name`. Default sort by `create_time DESC`.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import exhibition_appointment; print('OK')"`
Expected: "OK"
---
## Task 3: Backend CRUD — Clean Room + Access Log
**Files:**
- Create: `backend/app/crud/clean_room.py`
- [ ] **Step 1: Create CRUD file**
Create `backend/app/crud/clean_room.py` with two sets of CRUD functions:
**Clean Room functions:** `_row_to_dict`, `list_clean_rooms`, `get_clean_room`, `room_code_exists`, `create_clean_room`, `update_clean_room`, `soft_delete_clean_room`, `total_pages`. Keyword search on `room_code`, `room_name`. Filter by `tenant_no` and `del_flag==0`.
**Access Log functions:** `_access_log_to_dict`, `list_access_logs` (filter by `clean_room_id`), `get_access_log`, `create_access_log`, `update_access_log`, `soft_delete_access_log`. List supports `clean_room_id` filter + keyword on `person_name`, `dept_name`.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import clean_room; print('OK')"`
Expected: "OK"
---
## Task 4: Backend CRUD — Storage Facility (tree) + Slot
**Files:**
- Create: `backend/app/crud/storage_facility.py`
- [ ] **Step 1: Create CRUD file**
Create `backend/app/crud/storage_facility.py` with:
**Facility tree functions:** `_row_to_dict`, `list_all_facilities` (returns flat list, filtered by `tenant_no`, `del_flag==0`), `get_facility`, `create_facility`, `update_facility`, `soft_delete_facility` (cascade: soft-delete all children + their slots), `get_children` (find direct children of a node).
**Slot functions:** `_slot_to_dict`, `list_slots` (filter by `facility_node_id`), `get_slot`, `create_slot`, `update_slot`, `soft_delete_slot`.
**Tree helpers:** `build_tree` — converts flat list to nested tree structure with `children` array. `collect_descendant_ids` — recursively collects all descendant node IDs for cascade delete.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import storage_facility; print('OK')"`
Expected: "OK"
---
## Task 5: Backend API Routes — operations.py
**Files:**
- Create: `backend/app/api/operations.py`
- [ ] **Step 1: Create the API route file**
Create `backend/app/api/operations.py` with `router = APIRouter(prefix="/web/admin", tags=["运营支撑"])`. Include:
**Clean Room endpoints:**
- `GET /clean-rooms` — paginated list with keyword
- `POST /clean-rooms` — create
- `PUT /clean-rooms/{room_id}` — update
- `DELETE /clean-rooms/{room_id}` — soft delete
**Access Log endpoints:**
- `GET /clean-room-access-logs` — list by clean_room_id, paginated
- `POST /clean-room-access-logs` — create
- `PUT /clean-room-access-logs/{log_id}` — update
- `DELETE /clean-room-access-logs/{log_id}` — soft delete
**Storage Facility endpoints:**
- `GET /storage-facilities` — flat list (frontend builds tree)
- `POST /storage-facilities` — create node
- `PUT /storage-facilities/{node_id}` — update node
- `DELETE /storage-facilities/{node_id}` — cascade soft delete
**Storage Slot endpoints:**
- `GET /storage-facility-slots` — list by facility_node_id
- `POST /storage-facility-slots` — create
- `PUT /storage-facility-slots/{slot_id}` — update
- `DELETE /storage-facility-slots/{slot_id}` — soft delete
**Exhibition Appointment endpoints:**
- `GET /exhibition-appointments` — paginated list with keyword
- `POST /exhibition-appointments` — create
- `PUT /exhibition-appointments/{appointment_id}` — update
- `DELETE /exhibition-appointments/{appointment_id}` — soft delete
All endpoints use `Depends(require_admin_or_super_admin)` and `_get_tenant_no(db, user)`.
Each endpoint follows the pattern from `basic_info.py`: Pydantic body classes inline with `ConfigDict(populate_by_name=True)` and `Field(..., alias="camelCase")`. List endpoints return `success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})`.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.operations import router; print(f'Routes: {len(router.routes)}')"`
Expected: "Routes: 13"
---
## Task 6: Backend Router Registration
**Files:**
- Modify: `backend/app/api/__init__.py`
- Modify: `backend/app/main.py`
- [ ] **Step 1: Register in __init__.py**
Add to `backend/app/api/__init__.py`:
```python
from app.api.operations import router as operations_router
```
And add `"operations_router"` to `__all__`.
- [ ] **Step 2: Register in main.py**
Add to `backend/app/main.py` line 13 import section:
```python
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router
```
Add after line 67:
```python
app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
```
- [ ] **Step 3: Verify app starts**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.main import app; print(f'App routes: {len(app.routes)}')"`
Expected: A number > previous route count
---
## Task 7: Backend Tests
**Files:**
- Create: `backend/tests/test_operations_api.py`
- [ ] **Step 1: Write test file**
Create `backend/tests/test_operations_api.py` with tests for each module:
1. **Clean Room tests:** test_create_clean_room, test_list_clean_rooms, test_update_clean_room, test_delete_clean_room
2. **Access Log tests:** test_create_access_log, test_list_access_logs_by_room
3. **Storage Facility tests:** test_create_facility_node, test_list_facilities_flat, test_create_child_node, test_cascade_delete
4. **Slot tests:** test_create_slot, test_list_slots_by_node
5. **Exhibition Appointment tests:** test_create_appointment, test_list_appointments, test_update_appointment, test_delete_appointment
Use `pytest` with FastAPI `TestClient`. Each test creates, reads, updates, deletes and verifies. Tests can use the running dev server or TestClient.
- [ ] **Step 2: Run tests**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -m pytest tests/test_operations_api.py -v`
Expected: All tests PASS
---
## Task 8: Frontend API Functions
**Files:**
- Modify: `web-admin/src/api/admin.ts`
- [ ] **Step 1: Add API functions for all 3 modules**
Append to `web-admin/src/api/admin.ts` three new section blocks:
**Clean Room section:**
```typescript
// ---------------------------------------------------------------------------
// 洁净室管理
// ---------------------------------------------------------------------------
export function fetchCleanRoomList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get({ url: '/web/admin/clean-rooms', params })
}
export function fetchCreateCleanRoom(data: Record<string, any>) {
return request.post({ url: '/web/admin/clean-rooms', params: data })
}
export function fetchUpdateCleanRoom(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/clean-rooms/${id}`, params: data })
}
export function fetchDeleteCleanRoom(id: string) {
return request.del({ url: `/web/admin/clean-rooms/${id}` })
}
// 人员进出
export function fetchAccessLogList(params?: { page?: number; pageSize?: number; cleanRoomId?: string; keyword?: string }) {
return request.get({ url: '/web/admin/clean-room-access-logs', params })
}
export function fetchCreateAccessLog(data: Record<string, any>) {
return request.post({ url: '/web/admin/clean-room-access-logs', params: data })
}
export function fetchUpdateAccessLog(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/clean-room-access-logs/${id}`, params: data })
}
export function fetchDeleteAccessLog(id: string) {
return request.del({ url: `/web/admin/clean-room-access-logs/${id}` })
}
```
**Storage Facility section:**
```typescript
// ---------------------------------------------------------------------------
// 库位管理
// ---------------------------------------------------------------------------
export function fetchStorageFacilityList(params?: { keyword?: string }) {
return request.get({ url: '/web/admin/storage-facilities', params })
}
export function fetchCreateStorageFacility(data: Record<string, any>) {
return request.post({ url: '/web/admin/storage-facilities', params: data })
}
export function fetchUpdateStorageFacility(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/storage-facilities/${id}`, params: data })
}
export function fetchDeleteStorageFacility(id: string) {
return request.del({ url: `/web/admin/storage-facilities/${id}` })
}
// 格子
export function fetchStorageSlotList(params?: { facilityNodeId?: string }) {
return request.get({ url: '/web/admin/storage-facility-slots', params })
}
export function fetchCreateStorageSlot(data: Record<string, any>) {
return request.post({ url: '/web/admin/storage-facility-slots', params: data })
}
export function fetchUpdateStorageSlot(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/storage-facility-slots/${id}`, params: data })
}
export function fetchDeleteStorageSlot(id: string) {
return request.del({ url: `/web/admin/storage-facility-slots/${id}` })
}
```
**Exhibition Appointment section:**
```typescript
// ---------------------------------------------------------------------------
// 展馆预约
// ---------------------------------------------------------------------------
export function fetchExhibitionAppointmentList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get({ url: '/web/admin/exhibition-appointments', params })
}
export function fetchCreateExhibitionAppointment(data: Record<string, any>) {
return request.post({ url: '/web/admin/exhibition-appointments', params: data })
}
export function fetchUpdateExhibitionAppointment(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/exhibition-appointments/${id}`, params: data })
}
export function fetchDeleteExhibitionAppointment(id: string) {
return request.del({ url: `/web/admin/exhibition-appointments/${id}` })
}
```
- [ ] **Step 2: Verify no TypeScript errors**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck 2>&1 | tail -5`
Expected: No errors related to new functions
---
## Task 9: Frontend Router — Add 运营支撑 Group
**Files:**
- Modify: `web-admin/src/router/modules/sqxp.ts`
- [ ] **Step 1: Add route group**
Add before the closing `]` of `sqxpAdminRoutes` (before the last `}` or after the Workflow section):
```typescript
,
{
id: '60000000000000000000000000000001',
name: 'Operations',
path: '/operations',
component: '/index/index',
meta: {
title: '运营支撑',
icon: 'operation',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '60000000000000000000000000000002',
name: 'CleanRoom',
path: 'clean-room',
component: '/operations/clean-room/index',
meta: { title: '洁净室管理', keepAlive: true }
},
{
id: '60000000000000000000000000000003',
name: 'StorageFacility',
path: 'storage-facility',
component: '/operations/storage-facility/index',
meta: { title: '库位管理', keepAlive: true }
},
{
id: '60000000000000000000000000000004',
name: 'ExhibitionAppointment',
path: 'exhibition-appointment',
component: '/operations/exhibition-appointment/index',
meta: { title: '展馆预约', keepAlive: true }
}
]
}
```
- [ ] **Step 2: Verify routes compile**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck 2>&1 | tail -5`
Expected: No route-related errors
---
## Task 10: Frontend — Clean Room Page
**Files:**
- Create: `web-admin/src/views/operations/clean-room/index.vue`
- [ ] **Step 1: Create the Vue page**
Create `web-admin/src/views/operations/clean-room/index.vue` following the `system/unit/index.vue` template exactly:
**Page structure:**
- `page-container` → `header.page-head` → `section.panel`
- Filter: keyword search
- Table columns: 编号(roomCode), 名称(roomName), 等级(roomGrade, tag display: A级绿色/B级蓝色/C级黄色/D级灰色), 状态(roomStatus, tag: 正常=success/异常=danger), 面积(areaSqm), 操作
- Toolbar: 新增按钮, 批量删除按钮
- Dialog form (label-position="top", form-grid-2):
- 编号(roomCode, required), 名称(roomName, required), 等级(roomGrade, select: A/B/C/D), 状态(roomStatus, select: 正常zc/异常yc), 面积(areaSqm, number input, suffix "m²"), 备注(remark)
- 操作列: 查看, 编辑, 人员进出, 删除
- "人员进出"按钮 → 打开新弹窗显示该洁净室的进出记录列表(带分页), 弹窗内支持新增/编辑/删除进出记录
- 进出记录表单: 人员姓名(personName), 部门(deptName), 进入时间(enterTime, datetime), 目的(purpose), 离开时间(leaveTime, datetime), 体温(bodyTemp), 备注(remark)
- View dialog using SystemViewDialog component
- API calls: `fetchCleanRoomList`, `fetchCreateCleanRoom`, `fetchUpdateCleanRoom`, `fetchDeleteCleanRoom`, `fetchAccessLogList`, `fetchCreateAccessLog`, `fetchUpdateAccessLog`, `fetchDeleteAccessLog`
- [ ] **Step 2: Verify page renders**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev`, then navigate to `/#/operations/clean-room`
Expected: Page loads without console errors, shows empty list with filter bar and toolbar
---
## Task 11: Frontend — Storage Facility Page (Tree)
**Files:**
- Create: `web-admin/src/views/operations/storage-facility/index.vue`
- [ ] **Step 1: Create the Vue page**
Create `web-admin/src/views/operations/storage-facility/index.vue` following the `system/category/index.vue` tree template:
**Page structure:**
- Left panel: tree structure display using `el-table` with `row-key="id"` and `:tree-props="{ children: 'children' }"`
- Data fetched as flat list via `fetchStorageFacilityList`, then `buildTree()` converts to nested tree
- Tree columns: 节点名称(nodeName), 节点类型(nodeType, display as tag: facility=设施/layer=层/box=盒), 是否叶子(isLeaf, icon or tag), 备注(remark)
- Toolbar: 新增根节点, 展开全部, 折叠全部
- 操作列: 新增子节点, 编辑, 删除(需确认)
- Right panel: when a leaf node (`isLeaf===1`) is selected, show its slots (格子) list
- Slot table: 编码(slotCode), 状态(slotStatus), 操作(编辑/删除)
- Add slot button
- Dialog form for facility node (label-position="top", form-grid-2):
- 父节点(parentId, select from tree list, show full path via `buildTreePathLabelFromFlat`), 节点名称(nodeName, required), 节点类型(nodeType, select: facility/layer/box), 是否叶子(isLeaf, switch), 备注(remark)
- Dialog form for slot:
- 编码(slotCode, required), 状态(slotStatus, select), 备注(remark)
- Tree utilities: import `buildTreePathLabelFromFlat` from `@/utils/tree-path`
- API calls: `fetchStorageFacilityList`, `fetchCreateStorageFacility`, `fetchUpdateStorageFacility`, `fetchDeleteStorageFacility`, `fetchStorageSlotList`, `fetchCreateStorageSlot`, `fetchUpdateStorageSlot`, `fetchDeleteStorageSlot`
- [ ] **Step 2: Verify page renders**
Navigate to `/#/operations/storage-facility`
Expected: Page loads, shows empty tree with toolbar
---
## Task 12: Frontend — Exhibition Appointment Page
**Files:**
- Create: `web-admin/src/views/operations/exhibition-appointment/index.vue`
- [ ] **Step 1: Create the Vue page**
Create `web-admin/src/views/operations/exhibition-appointment/index.vue` following the `system/unit/index.vue` standard list template:
**Page structure:**
- Filter: keyword search (搜索预约编号/来访单位/联系人)
- Table columns: 预约编号(appointmentNo), 来访单位(visitOrg), 联系人(contactName), 到访日期(visitDate), 人数(visitorCount), 接待人员(receptionUserId → display name), 操作
- Toolbar: 新增按钮, 批量删除按钮
- Dialog form (label-position="top", form-grid-2):
- 预约编号(appointmentNo, required), 来访单位(visitOrg, required), 联系人(contactName), 到访日期(visitDate, date picker), 人数(visitorCount, number input), 接待人员(receptionUserId, select from user options), 备注(remark)
- View dialog using SystemViewDialog component
- API calls: `fetchExhibitionAppointmentList`, `fetchCreateExhibitionAppointment`, `fetchUpdateExhibitionAppointment`, `fetchDeleteExhibitionAppointment`
- [ ] **Step 2: Verify page renders**
Navigate to `/#/operations/exhibition-appointment`
Expected: Page loads without errors, shows empty list
---
## Task 13: Integration Test & Verification
- [ ] **Step 1: Start backend and verify all API endpoints**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -m pytest tests/test_operations_api.py -v`
Expected: All tests pass
- [ ] **Step 2: Start frontend and verify all 3 pages**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev`
Navigate to each page:
- `/#/operations/clean-room` — verify list, create dialog, access log dialog
- `/#/operations/storage-facility` — verify tree display, node CRUD, slot CRUD
- `/#/operations/exhibition-appointment` — verify list, create/edit dialog
- [ ] **Step 3: Run frontend typecheck**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck`
Expected: No TypeScript errors
- [ ] **Step 4: Run frontend lint**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm lint`
Expected: No lint errors (or only pre-existing ones)
---
## Self-Review Checklist
1. **Spec coverage**: All 5 tables have models ✓, all 3 modules have CRUD ✓, API ✓, frontend ✓
2. **Placeholder scan**: No TBD/TODO, all steps have actual code or descriptions ✓
3. **Type consistency**: Model field names match CRUD `_row_to_dict` keys match API aliases ✓
4. **Convention compliance**: tenant_no filter ✓, soft delete ✓, camelCase API ✓, Fresh Mint colors ✓