|
|
@@ -0,0 +1,497 @@
|
|
|
+# ETC/加油充值申请 · 撤回 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:** 为 ETC/加油充值申请增加「审批中 → 草稿」撤回能力,Web 管理端与小程序均可由申请人本人操作,行为对齐报销申请撤回。
|
|
|
+
|
|
|
+**Architecture:** 后端在 `pl_vehicle_recharge_service` 新增 `withdraw_apply`,挂 `POST .../vehicle-recharge-apply/{id}/withdraw`。状态 `in_approval` → `draft`,清空 `current_node`,保留审批记录与 `approval_round`。前端两端增加 `canWithdraw` 入口与 API 封装。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI / SQLAlchemy / pytest;Vue 3 + Element Plus(web-admin);uni-app(miniprogram)
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-29-pl-vehicle-recharge-apply-withdraw-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `backend/app/services/pl_vehicle_recharge_service.py` | `withdraw_apply` 业务逻辑 |
|
|
|
+| `backend/app/api/pl/vehicle_recharge.py` | HTTP 路由 |
|
|
|
+| `backend/tests/test_pl_vehicle_recharge_workflow.py` | 撤回成功 / 权限 / 状态单测 |
|
|
|
+| `web-admin/src/api/pl/index.ts` | `vehicleRechargeApplyApi.withdraw` |
|
|
|
+| `web-admin/src/views/pl/vehicle-recharge-apply/index.vue` | 工具栏 + 查看弹窗撤回 |
|
|
|
+| `miniprogram/api/pl.js` | `vehicleRechargeApplyApi.withdraw` |
|
|
|
+| `miniprogram/pages/pl/vehicle-recharge-apply/list.vue` | 列表卡片撤回按钮 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端单测(先失败)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/tests/test_pl_vehicle_recharge_workflow.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在文件末尾追加三个测试**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_withdraw_in_approval_to_draft_keeps_round_and_approvals(monkeypatch):
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_vehicle(db)
|
|
|
+ wf_id, node_gm, _node_fin = _seed_workflow(db)
|
|
|
+ db.add(
|
|
|
+ PlVehicleRechargeApply(
|
|
|
+ id="a-wd",
|
|
|
+ apply_no="SXSQWD001",
|
|
|
+ license_plate="鄂A00001",
|
|
|
+ recharge_type="etc",
|
|
|
+ apply_date=date.today(),
|
|
|
+ applicant_id="u-applicant",
|
|
|
+ status="in_approval",
|
|
|
+ wf_id=wf_id,
|
|
|
+ current_node=node_gm,
|
|
|
+ approval_round=1,
|
|
|
+ tenant_no="t1",
|
|
|
+ create_by="u-applicant",
|
|
|
+ create_dept_id="d1",
|
|
|
+ create_time=datetime.now(),
|
|
|
+ update_time=datetime.now(),
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.add(
|
|
|
+ PlVehicleRechargeApproval(
|
|
|
+ id="ap1",
|
|
|
+ apply_id="a-wd",
|
|
|
+ node_id=node_gm,
|
|
|
+ node_name="总经理审批",
|
|
|
+ approval_round=1,
|
|
|
+ approval_status="approved",
|
|
|
+ approver_id="u-gm",
|
|
|
+ approver_name="总经理",
|
|
|
+ approval_time=datetime.now(),
|
|
|
+ tenant_no="t1",
|
|
|
+ create_by="u-gm",
|
|
|
+ create_dept_id="d1",
|
|
|
+ create_time=datetime.now(),
|
|
|
+ update_time=datetime.now(),
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ _patch_scope(monkeypatch, svc)
|
|
|
+
|
|
|
+ out = svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd")
|
|
|
+ assert out.status == "draft"
|
|
|
+ assert out.currentNode in (None, "")
|
|
|
+ row = db.query(PlVehicleRechargeApply).filter_by(id="a-wd").one()
|
|
|
+ assert row.status == "draft"
|
|
|
+ assert row.current_node is None
|
|
|
+ assert row.approval_round == 1
|
|
|
+ assert (
|
|
|
+ db.query(PlVehicleRechargeApproval)
|
|
|
+ .filter_by(apply_id="a-wd", del_flag=0)
|
|
|
+ .count()
|
|
|
+ == 1
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_withdraw_rejects_non_applicant(monkeypatch):
|
|
|
+ from fastapi import HTTPException
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_vehicle(db)
|
|
|
+ db.add(
|
|
|
+ PlVehicleRechargeApply(
|
|
|
+ id="a-wd2",
|
|
|
+ apply_no="SXSQWD002",
|
|
|
+ license_plate="鄂A00001",
|
|
|
+ recharge_type="etc",
|
|
|
+ apply_date=date.today(),
|
|
|
+ applicant_id="u-applicant",
|
|
|
+ status="in_approval",
|
|
|
+ tenant_no="t1",
|
|
|
+ create_by="u-applicant",
|
|
|
+ create_dept_id="d1",
|
|
|
+ create_time=datetime.now(),
|
|
|
+ update_time=datetime.now(),
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ _patch_scope(monkeypatch, svc)
|
|
|
+
|
|
|
+ with pytest.raises(HTTPException) as ei:
|
|
|
+ svc.withdraw_apply(db, "t1", _user("u-other"), "a-wd2")
|
|
|
+ assert ei.value.status_code == 403
|
|
|
+
|
|
|
+
|
|
|
+def test_withdraw_rejects_non_in_approval(monkeypatch):
|
|
|
+ from fastapi import HTTPException
|
|
|
+ from app.services import pl_vehicle_recharge_service as svc
|
|
|
+
|
|
|
+ db = _make_session()
|
|
|
+ _seed_vehicle(db)
|
|
|
+ db.add(
|
|
|
+ PlVehicleRechargeApply(
|
|
|
+ id="a-wd3",
|
|
|
+ apply_no="SXSQWD003",
|
|
|
+ license_plate="鄂A00001",
|
|
|
+ recharge_type="etc",
|
|
|
+ apply_date=date.today(),
|
|
|
+ applicant_id="u-applicant",
|
|
|
+ status="draft",
|
|
|
+ tenant_no="t1",
|
|
|
+ create_by="u-applicant",
|
|
|
+ create_dept_id="d1",
|
|
|
+ create_time=datetime.now(),
|
|
|
+ update_time=datetime.now(),
|
|
|
+ del_flag=0,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ _patch_scope(monkeypatch, svc)
|
|
|
+
|
|
|
+ with pytest.raises(HTTPException) as ei:
|
|
|
+ svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd3")
|
|
|
+ assert ei.value.status_code == 400
|
|
|
+```
|
|
|
+
|
|
|
+若 `PlVehicleRechargeApproval` 构造字段与模型不一致,以 `backend/app/models/pl_models.py` 中该类字段为准微调(必填字段补齐即可)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_workflow.py::test_withdraw_in_approval_to_draft_keeps_round_and_approvals tests/test_pl_vehicle_recharge_workflow.py::test_withdraw_rejects_non_applicant tests/test_pl_vehicle_recharge_workflow.py::test_withdraw_rejects_non_in_approval -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(`withdraw_apply` 不存在或 AttributeError)
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit 失败测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/tests/test_pl_vehicle_recharge_workflow.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+test: add failing cases for vehicle recharge apply withdraw
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 实现 `withdraw_apply` + 路由
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(紧挨 `cancel_apply` 之后)
|
|
|
+- Modify: `backend/app/api/pl/vehicle_recharge.py`(紧挨 `cancel_apply` 路由之后)
|
|
|
+
|
|
|
+- [ ] **Step 1: 实现 service**
|
|
|
+
|
|
|
+在 `cancel_apply` 函数后插入:
|
|
|
+
|
|
|
+```python
|
|
|
+def withdraw_apply(
|
|
|
+ db: Session,
|
|
|
+ tenant_no: str,
|
|
|
+ user: DbUserContext,
|
|
|
+ apply_id: str,
|
|
|
+ *,
|
|
|
+ mine_vehicles: bool = False,
|
|
|
+) -> VehicleRechargeApplyRead:
|
|
|
+ row = _get_apply_row(
|
|
|
+ db, tenant_no, apply_id, user, mine_vehicles=mine_vehicles
|
|
|
+ )
|
|
|
+ if (row.applicant_id or "").strip() != (user.id or "").strip():
|
|
|
+ raise HTTPException(status_code=403, detail="仅申请人可撤回")
|
|
|
+ if row.status != "in_approval":
|
|
|
+ raise HTTPException(status_code=400, detail="仅审批中的申请可撤回")
|
|
|
+ row.status = "draft"
|
|
|
+ row.current_node = None
|
|
|
+ row.update_by = user.id
|
|
|
+ row.update_time = datetime.now()
|
|
|
+ db.commit()
|
|
|
+ db.refresh(row)
|
|
|
+ return apply_to_read(row, db, include_detail=True)
|
|
|
+```
|
|
|
+
|
|
|
+说明:申请人校验**始终执行**(不依赖 `mine_vehicles`),与设计 D5 一致。`approval_round` / 审批行不改动。
|
|
|
+
|
|
|
+- [ ] **Step 2: 挂路由**
|
|
|
+
|
|
|
+在 `cancel_apply` 路由后增加:
|
|
|
+
|
|
|
+```python
|
|
|
+@apply_router.post("/{id}/withdraw", response_model=VehicleRechargeApplyRead)
|
|
|
+def withdraw_apply(
|
|
|
+ id: str,
|
|
|
+ mine_vehicles: bool = Query(default=False, alias="mineVehicles"),
|
|
|
+ db: Session = Depends(get_db),
|
|
|
+ user: DbUserContext = Depends(get_db_current_user),
|
|
|
+) -> VehicleRechargeApplyRead:
|
|
|
+ tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
|
|
|
+ return svc.withdraw_apply(
|
|
|
+ db, tenant_no, user, id, mine_vehicles=mine_vehicles
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 跑测确认通过**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_workflow.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 全部 PASS(含既有用例 + 三个撤回用例)
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/pl_vehicle_recharge_service.py backend/app/api/pl/vehicle_recharge.py backend/tests/test_pl_vehicle_recharge_workflow.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(api): allow withdraw of in-approval vehicle recharge applies
|
|
|
+
|
|
|
+Applicants can return an in-approval apply to draft while keeping prior approval rows.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Web API + 列表/查看撤回
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/pl/index.ts`
|
|
|
+- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: API 增加 withdraw**
|
|
|
+
|
|
|
+在 `vehicleRechargeApplyApi` 的 `cancel` 旁增加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ withdraw: (id: string) =>
|
|
|
+ request.post<VehicleRechargeApply>(`/transport/vehicle-recharge-apply/${id}/withdraw`),
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 页面逻辑**
|
|
|
+
|
|
|
+1. 从 `@element-plus/icons-vue` 增加导入 `RefreshLeft`。
|
|
|
+2. 增加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+const withdrawLoadingMap = reactive<Record<string, boolean>>({})
|
|
|
+
|
|
|
+function canWithdraw(row?: VehicleRechargeApply | null) {
|
|
|
+ if (!row) return false
|
|
|
+ return (
|
|
|
+ row.status === 'in_approval' &&
|
|
|
+ String(row.applicantId || '') === String(currentUserId.value || '')
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+async function handleWithdraw(row: VehicleRechargeApply) {
|
|
|
+ if (!canWithdraw(row)) {
|
|
|
+ ElMessage.warning('仅本人申请且审批中的单据可撤回')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ '确定撤回该充值申请?撤回后变为草稿,可修改后再提交。',
|
|
|
+ '撤回确认',
|
|
|
+ {
|
|
|
+ confirmButtonText: '确定撤回',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ )
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ withdrawLoadingMap[row.id] = true
|
|
|
+ try {
|
|
|
+ await vehicleRechargeApplyApi.withdraw(row.id)
|
|
|
+ ElMessage.success('已撤回')
|
|
|
+ await fetchData()
|
|
|
+ if (viewVisible.value && viewingRecord.value?.id === row.id) {
|
|
|
+ // 与页面现有查看刷新方式一致(重新 get 详情)
|
|
|
+ const detail = await vehicleRechargeApplyApi.get(row.id)
|
|
|
+ viewingRecord.value = (detail as any)?.data || detail
|
|
|
+ }
|
|
|
+ } catch (e: any) {
|
|
|
+ ElMessage.error(e?.message || '撤回失败')
|
|
|
+ } finally {
|
|
|
+ withdrawLoadingMap[row.id] = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleToolbarWithdraw() {
|
|
|
+ if (selectedRows.value.length !== 1) {
|
|
|
+ ElMessage.warning('请先勾选一条申请')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ handleWithdraw(selectedRows.value[0])
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+3. 工具栏「作废」按钮后增加:
|
|
|
+
|
|
|
+```vue
|
|
|
+ <el-button
|
|
|
+ class="btn-ghost"
|
|
|
+ :disabled="selectedRows.length !== 1 || !canWithdraw(selectedRows[0])"
|
|
|
+ :loading="selectedRows.length === 1 && !!withdrawLoadingMap[selectedRows[0].id]"
|
|
|
+ @click="handleToolbarWithdraw"
|
|
|
+ >
|
|
|
+ <el-icon><RefreshLeft /></el-icon>撤回
|
|
|
+ </el-button>
|
|
|
+```
|
|
|
+
|
|
|
+4. 查看弹窗 `#hero-actions`:在现有 `draft/rejected` 模板旁增加 `v-else-if="canWithdraw(viewingRecord)"` 分支,仅「撤回」按钮(对齐报销)。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/pl/index.ts web-admin/src/views/pl/vehicle-recharge-apply/index.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): add withdraw action on vehicle recharge apply
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 小程序 API + 列表撤回
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `miniprogram/api/pl.js`
|
|
|
+- Modify: `miniprogram/pages/pl/vehicle-recharge-apply/list.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: API**
|
|
|
+
|
|
|
+在 `vehicleRechargeApplyApi.cancel` 旁增加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ withdraw: (id) =>
|
|
|
+ db.post(
|
|
|
+ `/transport/vehicle-recharge-apply/${id}/withdraw`,
|
|
|
+ {},
|
|
|
+ { params: { mineVehicles: true } }
|
|
|
+ ),
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 列表**
|
|
|
+
|
|
|
+1. 增加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+function canWithdraw(item) {
|
|
|
+ if (!item) return false
|
|
|
+ if (item.status !== 'in_approval') return false
|
|
|
+ return String(item.applicantId || '') === currentUserId()
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+2. 在卡片 `op-actions` 中,保留「复制发群」在最前;`canWrite` 之外改为:
|
|
|
+
|
|
|
+```vue
|
|
|
+ <template v-if="canWrite(item)">
|
|
|
+ <button class="btn btn-sm btn-ghost" @click="handleSubmit(item)">提交</button>
|
|
|
+ <button class="btn btn-sm btn-primary" @click="goForm(item.id)">编辑</button>
|
|
|
+ <button class="btn btn-sm btn-danger" @click="handleCancel(item)">作废</button>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="canWithdraw(item)">
|
|
|
+ <button class="btn btn-sm btn-ghost" @click="handleWithdraw(item)">撤回</button>
|
|
|
+ <button class="btn btn-sm btn-primary" @click="goForm(item.id)">查看</button>
|
|
|
+ </template>
|
|
|
+ <button v-else class="btn btn-sm btn-primary" @click="goForm(item.id)">查看</button>
|
|
|
+```
|
|
|
+
|
|
|
+3. 增加 handler:
|
|
|
+
|
|
|
+```javascript
|
|
|
+function handleWithdraw(item) {
|
|
|
+ uni.showModal({
|
|
|
+ title: '撤回确认',
|
|
|
+ content: '确定撤回该充值申请?撤回后变为草稿,可修改后再提交。',
|
|
|
+ confirmText: '确定撤回',
|
|
|
+ success: async (res) => {
|
|
|
+ if (!res.confirm) return
|
|
|
+ try {
|
|
|
+ await vehicleRechargeApplyApi.withdraw(item.id)
|
|
|
+ uni.showToast({ title: '已撤回', icon: 'success' })
|
|
|
+ onRefresh()
|
|
|
+ } catch (e) {
|
|
|
+ // toasted by request layer
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/api/pl.js miniprogram/pages/pl/vehicle-recharge-apply/list.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(mini): add withdraw on ETC vehicle recharge apply list
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 图谱更新 + 回归确认
|
|
|
+
|
|
|
+**Files:** 无业务代码;运行脚本
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端回归**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_workflow.py tests/test_pl_vehicle_recharge_mine_vehicles.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 2: 更新知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh backend
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 手动验收清单(实现者勾选)**
|
|
|
+
|
|
|
+- Web:本人审批中单可选中 → 撤回 → 状态草稿 → 可编辑再提交
|
|
|
+- Web:非本人审批中 / 待发放:撤回按钮禁用
|
|
|
+- 小程序:同上
|
|
|
+- 作废仍仅草稿/驳回
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit 图谱(若有变更)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/graphify-out graphify-out
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+chore: update graphify after vehicle recharge withdraw
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+仅在有实际 diff 时提交。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage check
|
|
|
+
|
|
|
+| 规格项 | 任务 |
|
|
|
+|--------|------|
|
|
|
+| D1 审批中→草稿 | Task 2 |
|
|
|
+| D2 Web+小程序 | Task 3、4 |
|
|
|
+| D3 保留审批记录不切轮次 | Task 1/2 断言 |
|
|
|
+| D4 仅 `in_approval` | Task 1/2 |
|
|
|
+| D5 仅申请人 | Task 1/2 + 前端 `canWithdraw` |
|
|
|
+| D6 独立 withdraw API | Task 2 |
|
|
|
+| 待发放不可撤 | Task 1 状态校验 + 前端条件 |
|
|
|
+| 验收要点 | Task 5 清单 |
|