From a0775cf619be757da1c787f869027c8b91565011 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 11 Aug 2026 13:39:51 +0800 Subject: [PATCH] feat: wire mini menus API and OA start home entry for mobile Co-authored-by: Cursor --- backend/app/api/__init__.py | 2 + backend/app/api/mini_menu.py | 128 +++ backend/app/main.py | 3 +- docs/sql/mobile_menu_oa_start.sql | 39 + ...6-08-11-mobile-oa-start-approval-phase2.md | 773 ++++++++++++++++++ ...6-08-11-mobile-oa-form-sink-spike-notes.md | 10 + mobile-h5/pages/index/index.vue | 41 +- mobile-h5/utils/menu.js | 22 + 8 files changed, 1008 insertions(+), 10 deletions(-) create mode 100644 backend/app/api/mini_menu.py create mode 100644 docs/sql/mobile_menu_oa_start.sql create mode 100644 docs/superpowers/plans/2026-08-11-mobile-oa-start-approval-phase2.md create mode 100644 mobile-h5/utils/menu.js diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 6a379b6f..31e28d4d 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -35,6 +35,7 @@ from app.api.market import router as market_router from app.api.pre_transit_sample import router as pre_transit_sample_router from app.api.oa_approval import router as oa_approval_router from app.api.mini_auth import router as mini_auth_router +from app.api.mini_menu import router as mini_menu_router __all__ = [ "public_router", @@ -73,4 +74,5 @@ __all__ = [ "pre_transit_sample_router", "oa_approval_router", "mini_auth_router", + "mini_menu_router", ] diff --git a/backend/app/api/mini_menu.py b/backend/app/api/mini_menu.py new file mode 100644 index 00000000..ab0695c8 --- /dev/null +++ b/backend/app/api/mini_menu.py @@ -0,0 +1,128 @@ +"""小程序菜单:当前用户在「移动端菜单」树下有权限的入口。""" +from __future__ import annotations + +from typing import Dict, List, Set + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.core.auth import get_current_user_by_token +from app.core.config import settings +from app.core.database import get_db +from app.core.response import error_response, success_response +from app.models.models import SysMenu, SysRoleMenu +from app.services.web_auth_roles import get_effective_role_ids, resolve_web_role_for_user + +router = APIRouter(prefix="/mini", tags=["小程序菜单"]) + + +async def _current_user(request: Request, db: Session) -> dict: + authorization = request.headers.get("Authorization", "") + if not authorization.startswith("Bearer "): + raise ValueError("未授权") + token = authorization.replace("Bearer ", "", 1).strip() + return await get_current_user_by_token(token=token, db=db) + + +def _find_mobile_root(db: Session) -> SysMenu | None: + tenant = settings.DEFAULT_TENANT_NO + rows = ( + db.query(SysMenu) + .filter( + SysMenu.del_flag == 0, + SysMenu.parent_id == "0", + SysMenu.tenant_no == tenant, + ) + .all() + ) + for m in rows: + name = (m.menu_name or "").strip() + if "移动端" in name: + return m + return None + + +def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]: + tenant = settings.DEFAULT_TENANT_NO + all_menus = ( + db.query(SysMenu) + .filter( + SysMenu.del_flag == 0, + SysMenu.tenant_no == tenant, + SysMenu.status == "1", + SysMenu.visible == "1", + ) + .order_by(SysMenu.order_num.asc(), SysMenu.create_time.asc()) + .all() + ) + by_parent: Dict[str, List[SysMenu]] = {} + for m in all_menus: + pid = (m.parent_id or "0").strip() or "0" + by_parent.setdefault(pid, []).append(m) + + out: List[SysMenu] = [] + + def walk(pid: str) -> None: + for child in by_parent.get(pid, []): + out.append(child) + walk(child.id) + + walk(root_id) + return out + + +def _user_menu_ids(db: Session, user_id: str) -> Set[str]: + role_ids = get_effective_role_ids(db, user_id) + if not role_ids: + return set() + tenant = settings.DEFAULT_TENANT_NO + rows = ( + db.query(SysRoleMenu.menu_id) + .filter( + SysRoleMenu.del_flag == 0, + SysRoleMenu.tenant_no == tenant, + SysRoleMenu.role_id.in_(role_ids), + ) + .all() + ) + return {str(r[0]).strip() for r in rows if r and r[0]} + + +@router.get("/menus") +async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)): + try: + user = await _current_user(request, db) + except Exception: + return error_response(401, "未授权") + + user_id = str(user.get("user_id") or user.get("sub") or "").strip() + if not user_id: + return error_response(401, "未授权") + + root = _find_mobile_root(db) + if not root: + return success_response({"list": []}) + + descendants = _collect_descendants(db, root.id) + web_role = resolve_web_role_for_user(db, user_id) + allowed_ids = None if web_role == "super_admin" else _user_menu_ids(db, user_id) + + items = [] + for m in descendants: + if (m.menu_type or "").upper() == "F": + continue + path = (m.path or "").strip() + if not path: + continue + if allowed_ids is not None and m.id not in allowed_ids: + continue + items.append( + { + "id": m.id, + "title": m.menu_name, + "path": path if path.startswith("/") else f"/{path}", + "icon": "edit", + "perms": (m.perms or "").strip() or None, + } + ) + return success_response({"list": items}) diff --git a/backend/app/main.py b/backend/app/main.py index a38c29fe..75f3d56d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app.core.database import SessionLocal from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware from app.models import models # noqa: F401 -from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router +from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router @asynccontextmanager @@ -146,6 +146,7 @@ app.include_router(market_router, prefix=settings.API_V1_PREFIX) app.include_router(pre_transit_sample_router, prefix=settings.API_V1_PREFIX) app.include_router(oa_approval_router, prefix=settings.API_V1_PREFIX) app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX) +app.include_router(mini_menu_router, prefix=settings.API_V1_PREFIX) def _mask_secret(value: str) -> str: diff --git a/docs/sql/mobile_menu_oa_start.sql b/docs/sql/mobile_menu_oa_start.sql new file mode 100644 index 00000000..398312e4 --- /dev/null +++ b/docs/sql/mobile_menu_oa_start.sql @@ -0,0 +1,39 @@ +-- 小程序「发起审批」菜单种子(挂在「移动端菜单」根下) +-- 执行前请确认移动端根存在;角色授权请在 Web「角色管理」勾选本菜单。 + +SET @mini_root = ( + SELECT id FROM sys_menu + WHERE del_flag = 0 AND parent_id = '0' AND menu_name LIKE '%移动端%' + LIMIT 1 +); + +INSERT INTO sys_menu ( + id, menu_name, parent_id, menu_type, path, component, perms, + is_frame, visible, status, tenant_no, order_num, remark, + create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'MINI000000000000000000000000OA001', + '发起审批', + @mini_root, + 'C', + '/pages/oa/start/index', + NULL, + 'mini:oa:start', + 1, + '1', + '1', + 'zkxw', + 10, + '小程序发起审批', + 'admin', + '', + NOW(), + NULL, + NOW(), + 0 +FROM DUAL +WHERE @mini_root IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA001' AND del_flag = 0 + ); diff --git a/docs/superpowers/plans/2026-08-11-mobile-oa-start-approval-phase2.md b/docs/superpowers/plans/2026-08-11-mobile-oa-start-approval-phase2.md new file mode 100644 index 00000000..f6e2b36b --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-mobile-oa-start-approval-phase2.md @@ -0,0 +1,773 @@ +# 移动端发起审批 · 阶段二 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:** 在小程序落地「发起审批」:原生模板列表 + H5 正式填写页(动态表单 + 审批流预览 + 草稿/提交)+「移动端菜单」注册与首页联调。 + +**Architecture:** 对齐设计 `docs/superpowers/specs/2026-08-11-mobile-oa-start-approval-design.md` §6。小程序只做入口与列表;填写页在 `mobile-oa-h5` 的 `/oa-start`(web-view 全屏),复用 Web `/web/admin/oa-approval/*` + JWT。阶段一水槽已证明 form-create/vant 与自研 type 可用,本阶段接真实 schema 与运行时 API。 + +**Tech Stack:** Vue3、Vite、Vant4、`@form-create/vant`、uni-app 微信小程序、`web-view`、FastAPI(菜单种子 + 可选 mini 菜单接口) + +**Spec:** `docs/superpowers/specs/2026-08-11-mobile-oa-start-approval-design.md` + +**前置(阶段一已完成):** `mobile-oa-h5` 工程、水槽、`token` 透传、P0~P2 自研控件注册、真机水槽验收。 + +**Out of scope(本计划不做):** 待办/已办/抄送办理、Web 设计器改造、小程序内直挂 form-create、草稿箱独立列表页(仅支持发起页内暂存)、套件与 Web 100% 业务细节(请假节假日等放 Task 8 可分期)。 + +--- + +## File map + +| 路径 | 职责 | +|------|------| +| `mobile-oa-h5/src/api/oa.js` | 扩展 templates / flow-preview / drafts / instances | +| `mobile-oa-h5/src/oa/schema-prep.js` | 拉取后的 rule/option 预处理(labelAlign、upload ensure 等最小集) | +| `mobile-oa-h5/src/oa/components/OaFlowPreview.vue` | 移动端审批流预览 + initiator_select | +| `mobile-oa-h5/src/views/OaStart.vue` | 正式发起页:表单 + 流程 + 底栏 | +| `mobile-oa-h5/src/router.js` | `/oa-start` → `OaStart.vue` | +| `mobile-h5/pages/oa/start/index.vue` | 已发布模板卡片列表 | +| `mobile-h5/pages/oa/start/webview.vue` | 整页 web-view 打开 `/oa-start` | +| `mobile-h5/pages.json` | 注册上述路由 | +| `mobile-h5/utils/oa-h5.js` | 已有 `buildOaStartUrl`;按需微调 | +| `mobile-h5/pages/index/index.vue` | 接入真实/过渡菜单 | +| `mobile-h5/utils/menu.js` | 拉移动端菜单并映射 path | +| `docs/sql/mobile_menu_oa_start.sql` | 「发起审批」菜单种子(挂在移动端根下) | +| `backend/app/api/mini_menu.py`(或扩 `mini_auth`) | `GET /mini/menus` 当前用户移动端菜单 | +| `backend/app/api/__init__.py` + `main.py` | 注册路由 | + +**Web 对照(只读参考,勿改除非权限不够):** + +- 列表:`web-admin/src/views/oa-approval/start/index.vue` +- 弹窗:`web-admin/src/components/oa-approval/OaStartFormDialog.vue` +- 流程:`web-admin/src/components/oa-approval/OaFlowPreview.vue` +- API:`web-admin/src/api/oaApproval.ts`(`BASE = /web/admin/oa-approval`) + +--- + +## 目标用户路径 + +``` +首页「发起审批」 + → /pages/oa/start/index(模板卡片) + → 点「发起」 + → /pages/oa/start/webview?appId=… + → web-view: {OA_H5_ORIGIN}/oa-start?appId=&token= + → 填表 / 看审批流 / 暂存或提交 + → navigateBack → 列表或首页 +``` + +--- + +### Task 1: 扩展 H5 OA 运行时 API + +**Files:** +- Modify: `mobile-oa-h5/src/api/oa.js` + +- [ ] **Step 1: 在现有 `oa.js` 末尾追加运行时方法(保留已有 users/depts/upload 等)** + +```js +// —— 发起审批运行时(对齐 web-admin oaApproval.ts)—— + +export function fetchOaGroupList() { + return get('/web/admin/oa-approval/groups') +} + +export function fetchOaTemplates() { + return get('/web/admin/oa-approval/templates') +} + +export function fetchOaTemplateDetail(appId) { + return get(`/web/admin/oa-approval/templates/${encodeURIComponent(appId)}`) +} + +export function fetchOaFlowPreview(appId, { formData, initiatorSelectByNode } = {}) { + return post(`/web/admin/oa-approval/templates/${encodeURIComponent(appId)}/flow-preview`, { + formData: formData || {}, + initiatorSelectByNode: initiatorSelectByNode || undefined + }) +} + +export function fetchCreateOaInstanceDraft(data) { + return post('/web/admin/oa-approval/drafts', data) +} + +export function fetchUpdateOaInstanceDraft(id, data) { + return request({ + url: `/web/admin/oa-approval/drafts/${encodeURIComponent(id)}`, + method: 'PUT', + data + }) +} + +export function fetchOaInstanceDraftDetail(id) { + return get(`/web/admin/oa-approval/drafts/${encodeURIComponent(id)}`) +} + +export function fetchSubmitOaInstanceDraft(id, data) { + return post(`/web/admin/oa-approval/drafts/${encodeURIComponent(id)}/submit`, data) +} + +export function fetchStartOaInstance(data) { + return post('/web/admin/oa-approval/instances', data) +} +``` + +说明:若 `http.js` 尚无通用 `request` 的 PUT,补上 `export function put(url, data) { return request({ url, method: 'PUT', data }) }` 并改用 `put(...)`。 + +- [ ] **Step 2: 浏览器带 token 冒烟(后端已启)** + +```bash +# 将 TOKEN 换成小程序登录 JWT +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/api/web/admin/oa-approval/templates" | head -c 400 +``` + +Expected: JSON `code: 200` 且含 `list`(权限不足则记入 Task 7 权限核对,不阻塞前端骨架)。 + +- [ ] **Step 3: Commit** + +```bash +git add mobile-oa-h5/src/api/oa.js mobile-oa-h5/src/utils/http.js +git commit -m "feat(mobile-oa-h5): add OA start runtime API helpers" +``` + +--- + +### Task 2: schema 最小预处理 + +**Files:** +- Create: `mobile-oa-h5/src/oa/schema-prep.js` + +- [ ] **Step 1: 写入最小预处理(移动端强制顶栏 label + 关闭内置提交钮;递归保证 upload 有 props)** + +```js +import { oaFormOption } from './form-option.js' + +function walkRules(rules, fn) { + const list = Array.isArray(rules) ? rules : [] + for (const r of list) { + if (!r || typeof r !== 'object') continue + fn(r) + if (Array.isArray(r.children)) walkRules(r.children, fn) + } +} + +/** 与 Web ensureOaUploadRuleProps 同思路的最小版 */ +export function ensureOaUploadRuleProps(rules) { + const next = JSON.parse(JSON.stringify(rules || [])) + walkRules(next, (r) => { + if (r.type === 'upload' || r.type === 'fcUpload') { + r.props = { ...(r.props || {}) } + if (r.props.limit == null) r.props.limit = 9 + } + }) + return next +} + +export function prepareMobileFormSchema(formSchemaJson) { + const schema = formSchemaJson || {} + const rawRule = Array.isArray(schema.rule) + ? schema.rule + : Array.isArray(schema) + ? schema + : [] + const rule = ensureOaUploadRuleProps(rawRule) + const option = { + ...oaFormOption, + ...(schema.option || {}), + form: { + ...(schema.option?.form || {}), + labelAlign: 'top', + inputAlign: 'left', + colon: false + }, + submitBtn: false, + resetBtn: false + } + return { rule, option } +} +``` + +说明:Web 上 `ensureLeaveKitRuleProps` / owner 默认值等先不搬全量;表单能渲染、能提交即 MVP。Task 8 再对齐套件运行时。 + +- [ ] **Step 2: Commit** + +```bash +git add mobile-oa-h5/src/oa/schema-prep.js +git commit -m "feat(mobile-oa-h5): add minimal OA schema prep for mobile start" +``` + +--- + +### Task 3: 移动端审批流预览组件 + +**Files:** +- Create: `mobile-oa-h5/src/oa/components/OaFlowPreview.vue` + +对齐 Web `OaFlowPreview.vue` 的数据契约:`steps`、`resolved`、`pendingMessage`、`loading`、`initiatorSelectByNode`(`v-model`)、`userOptions`。 + +- [ ] **Step 1: 实现组件骨架** + +关键行为: + +1. `resolved === false` → 展示 `pendingMessage`(默认「必填信息填写后,流程将自动显示」)。 +2. `resolved === true` → 竖向步骤链:`start` / `approval` / `cc` / `end`;展示 `name`、会签/或签徽章、`meta`、`people` chips。 +3. 若节点 `assigneeTypes`(或 `assigneeType`)含 `initiator_select`:展示已选人 +「添加」→ `van-popup` 内多选人员(数据来自 `userOptions`:`{ id, nickName, userNo }`)。 +4. `emit('update:initiatorSelectByNode', { ...map })`。 + +模板结构示意(实现时补全样式,三墙绿): + +```vue + +``` + +辅助函数: + +```js +function isInitiatorSelect(s) { + const types = + Array.isArray(s.assigneeTypes) && s.assigneeTypes.length + ? s.assigneeTypes + : s.assigneeType + ? [s.assigneeType] + : [] + return types.includes('initiator_select') +} +``` + +Props: + +```js +defineProps({ + steps: { type: Array, default: () => [] }, + resolved: { type: Boolean, default: true }, + pendingMessage: { type: String, default: '' }, + loading: { type: Boolean, default: false }, + initiatorSelectByNode: { type: Object, default: () => ({}) }, + userOptions: { type: Array, default: () => [] } +}) +``` + +- [ ] **Step 2: 在浏览器临时把静态 steps 塞进水槽或独立路由自测布局**(可选;正式联调在 Task 4)。 + +- [ ] **Step 3: Commit** + +```bash +git add mobile-oa-h5/src/oa/components/OaFlowPreview.vue +git commit -m "feat(mobile-oa-h5): add mobile OaFlowPreview for start approval" +``` + +--- + +### Task 4: 正式页 `/oa-start`(表单 + 流程 + 草稿/提交) + +**Files:** +- Create: `mobile-oa-h5/src/views/OaStart.vue` +- Modify: `mobile-oa-h5/src/router.js` + +- [ ] **Step 1: 实现 `OaStart.vue` 核心逻辑** + +入参(`route.query`):`appId` 必填;可选 `draftId`、`token`(token 已由 `captureTokenFromUrl` 处理)。 + +页面结构: + +``` +header: 模板名称(或「发起审批」) +body: + form-create-mobile(上) + OaFlowPreview(下) +footer 固定: + 取消 | 暂存草稿 | 提交 +``` + +关键流程(对齐 `OaStartFormDialog`): + +```js +import { computed, onMounted, ref, watch } from 'vue' +import { useRoute } from 'vue-router' +import { showToast, showConfirmDialog } from 'vant' +import { + fetchOaTemplateDetail, + fetchOaFlowPreview, + fetchCreateOaInstanceDraft, + fetchUpdateOaInstanceDraft, + fetchOaInstanceDraftDetail, + fetchSubmitOaInstanceDraft, + fetchStartOaInstance, + fetchUserList +} from '../api/oa.js' +import { prepareMobileFormSchema } from '../oa/schema-prep.js' +import OaFlowPreview from '../oa/components/OaFlowPreview.vue' + +// formData, formRule, formOption, fapi +// editingDraftId, appMeta { id, appName } +// flowSteps, flowResolved, flowPendingMessage, previewLoading +// initiatorSelectByNode, userOptions + +async function loadByAppId(appId) { + const detail = await fetchOaTemplateDetail(appId) + appMeta.value = { id: appId, appName: detail?.appName || detail?.name || '发起审批' } + const prepared = prepareMobileFormSchema(detail?.formSchemaJson) + formRule.value = prepared.rule + formOption.value = prepared.option + formData.value = {} // 后续 Task 8 再补 owner/kit defaults + // 若 flow 含 initiator_select,分页拉 users(pageSize<=100) + await loadFlowPreview() +} + +async function loadFlowPreview() { + if (!appMeta.value?.id) return + previewLoading.value = true + try { + const preview = await fetchOaFlowPreview(appMeta.value.id, { + formData: { ...formData.value }, + initiatorSelectByNode: { ...initiatorSelectByNode.value } + }) + flowResolved.value = preview?.resolved !== false + flowPendingMessage.value = preview?.message || '必填信息填写后,流程将自动显示' + flowSteps.value = flowResolved.value ? preview?.steps || [] : [] + } catch (e) { + showToast(e?.message || '审批流预览失败') + flowSteps.value = [] + flowResolved.value = true + } finally { + previewLoading.value = false + } +} + +// watch formData + initiatorSelectByNode deep,300ms debounce → loadFlowPreview + +async function saveDraft() { /* create or update;成功 toast + closeToMini */ } +async function submit() { + // 1) initiator_select 节点必须有人选 + // 2) fapi.validate() + // 3) draft ? submitDraft : startInstance + // 4) closeToMini({ ok: true, instanceId }) +} + +function closeToMini(payload) { + try { + // 微信小程序 web-view + // eslint-disable-next-line no-undef + if (typeof wx !== 'undefined' && wx.miniProgram?.navigateBack) { + wx.miniProgram.postMessage({ data: payload || { action: 'oa-start-close' } }) + wx.miniProgram.navigateBack() + return + } + } catch (_) {} + showToast('已完成(非小程序环境)') +} +``` + +`onMounted`: + +```js +const route = useRoute() +const appId = String(route.query.appId || '').trim() +const draftId = String(route.query.draftId || '').trim() +if (draftId) { + const d = await fetchOaInstanceDraftDetail(draftId) + editingDraftId.value = draftId + await loadByAppId(d.appId) + formData.value = d.formData || {} + initiatorSelectByNode.value = d.initiatorSelectByNode || {} + await loadFlowPreview() +} else if (appId) { + await loadByAppId(appId) +} else { + showToast('缺少 appId') +} +``` + +底栏按钮须带图标习惯可简化为纯文字(H5 Vant);主按钮「提交」`type="primary"`。 + +- [ ] **Step 2: 改路由** + +```js +import FormSink from './views/FormSink.vue' +import OaStart from './views/OaStart.vue' + +// ... +{ path: '/oa-start', component: OaStart }, +``` + +- [ ] **Step 3: 本机验证** + +```bash +cd mobile-oa-h5 && npm run dev +# 打开: +# http://127.0.0.1:5178/oa-h5/oa-start?appId=<已发布模板ID>&token= +``` + +Expected: 表单按模板渲染;改字段后审批流刷新;暂存/提交不报前端契约错误。 + +- [ ] **Step 4: Commit** + +```bash +git add mobile-oa-h5/src/views/OaStart.vue mobile-oa-h5/src/router.js +git commit -m "feat(mobile-oa-h5): implement oa-start page with form, flow, draft and submit" +``` + +--- + +### Task 5: 小程序模板列表页 + +**Files:** +- Create: `mobile-h5/pages/oa/start/index.vue` +- Modify: `mobile-h5/pages.json` +- Create or Modify: `mobile-h5/utils/oa-api.js`(列表用小程序 `request`,不要走 H5) + +- [ ] **Step 1: 封装小程序侧 OA API** + +```js +// mobile-h5/utils/oa-api.js +import { get } from './request.js' + +export function fetchOaGroupList() { + return get('/web/admin/oa-approval/groups') +} + +export function fetchOaTemplates() { + return get('/web/admin/oa-approval/templates') +} +``` + +(`request.js` 的 base 已含 `/api`,path 勿再写 `/api`。) + +- [ ] **Step 2: 实现列表页(对齐 Web 卡片思路,三墙样式)** + +- 顶栏搜索(审批单名称) +- 分组 Tab:全部 + `groups` +- 分组 section + 卡片:`iconUrl`(可先占位色块)、`appName`、`description`、按钮「发起」 +- 点击「发起」→ `uni.navigateTo({ url: '/pages/oa/start/webview?appId=' + id })` + +分组展示逻辑可抄 Web `start/index.vue` 的 `displayGroups` 思路:按 `groupId` 归类,无分组归「未分组」。 + +- [ ] **Step 3: `pages.json` 注册** + +```json +{ + "path": "pages/oa/start/index", + "style": { + "navigationBarTitleText": "发起审批", + "navigationBarBackgroundColor": "#0a9f6d", + "navigationBarTextStyle": "white" + } +} +``` + +- [ ] **Step 4: 登录后进列表冒烟** Expected: 看到已发布模板;点发起进入下一页。 + +- [ ] **Step 5: Commit** + +```bash +git add mobile-h5/utils/oa-api.js mobile-h5/pages/oa/start/index.vue mobile-h5/pages.json +git commit -m "feat(mobile-h5): add OA start template list page" +``` + +--- + +### Task 6: 小程序 web-view 发起页 + +**Files:** +- Create: `mobile-h5/pages/oa/start/webview.vue` +- Modify: `mobile-h5/pages.json` +- Modify: `mobile-h5/utils/oa-h5.js`(确认 `buildOaStartUrl` 已带 token) + +- [ ] **Step 1: webview 页** + +```vue + + + +``` + +确认 `buildOaStartUrl`: + +```js +export function buildOaStartUrl({ appId, draftId }) { + const base = trimSlash(OA_H5_ORIGIN) + const q = new URLSearchParams() + if (appId) q.set('appId', appId) + if (draftId) q.set('draftId', draftId) + const token = getToken() + if (token) q.set('token', token) + return `${base}/oa-start?${q.toString()}` +} +``` + +- [ ] **Step 2: `pages.json` 注册 `pages/oa/start/webview`**(`navigationStyle` 可默认;标题「填写审批」)。 + +- [ ] **Step 3: 真机/开发者工具:列表 → 发起 → H5 能打开且带 token**。 + +- [ ] **Step 4: Commit** + +```bash +git add mobile-h5/pages/oa/start/webview.vue mobile-h5/pages.json mobile-h5/utils/oa-h5.js +git commit -m "feat(mobile-h5): add OA start web-view page" +``` + +--- + +### Task 7: 移动端菜单种子 + mini 菜单 API + 首页联调 + +**Files:** +- Create: `docs/sql/mobile_menu_oa_start.sql` +- Create: `backend/app/api/mini_menu.py` +- Modify: `backend/app/api/__init__.py`、`backend/app/main.py` +- Create: `mobile-h5/utils/menu.js` +- Modify: `mobile-h5/pages/index/index.vue` +- Modify: `mobile-h5/utils/request.js` 或 auth 按现有 get 封装 + +- [ ] **Step 1: SQL 种子(在测试库执行;先查移动端根 id)** + +```sql +-- 查移动端根(parent_id='0' 且名称含「移动端」) +SELECT id, menu_name FROM sys_menu +WHERE del_flag='0' AND parent_id='0' AND menu_name LIKE '%移动端%'; + +-- 假设根 id 为 @mini_root(请替换为真实 UUID) +SET @mini_root = ( + SELECT id FROM sys_menu + WHERE del_flag='0' AND parent_id='0' AND menu_name LIKE '%移动端%' + LIMIT 1 +); + +INSERT INTO sys_menu ( + id, menu_name, parent_id, menu_type, path, component, perms, + order_num, visible, status, tenant_no, create_by, create_time, del_flag, remark +) VALUES ( + REPLACE(UUID(),'-',''), -- 或固定 UUID 便于幂等 + '发起审批', + @mini_root, + 'C', + '/pages/oa/start/index', + NULL, + 'mini:oa:start', + 10, + '1', + '1', + 'zkxw', -- 按租户实际调整 + 'admin', + NOW(), + '0', + '小程序发起审批' +); +``` + +然后在 Web「角色管理」给目标角色勾选该菜单。字段名以 `docs/sql/zkxw.sql` / `SysMenu` 模型为准,实施时对照列名微调(`menu_name`/`path`/`perms`/`order_num` 等)。 + +- [ ] **Step 2: 后端 `GET /api/mini/menus`** + +```python +# backend/app/api/mini_menu.py +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user # 以项目实际依赖名为准 +from app.core.response import success_response +from app.models.models import SysMenu +from app.core.menu_guard import get_user_menu_codes + +router = APIRouter(prefix="/mini", tags=["小程序菜单"]) + +@router.get("/menus") +async def list_my_mobile_menus( + db: Session = Depends(get_db), + user: dict = Depends(get_current_user), +): + """返回当前用户在「移动端菜单」树下有权限的叶子/菜单项(扁平列表)。""" + # 1) 找到 parent_id=0 且名称含「移动端」的根 + # 2) 取该根下所有 del_flag=0 的菜单 + # 3) 用 get_user_menu_codes / role_menu 过滤(super_admin 可返回全部) + # 4) 返回 [{ id, title: menu_name, path, icon: icon, perms }] + ... + return success_response({"list": items}) +``` + +注册:`app/api/__init__.py` + `app/main.py` `include_router(..., prefix=settings.API_V1_PREFIX)`。 + +鉴权:与 `mini_auth` 相同 JWT;若现有 `get_current_user` 已覆盖小程序 token,直接复用。 + +- [ ] **Step 3: 小程序 `menu.js` + 首页** + +```js +// mobile-h5/utils/menu.js +import { get } from './request.js' + +export async function fetchMyMobileMenus() { + const data = await get('/mini/menus') + const list = data?.list || data || [] + return list.map((m) => ({ + id: m.id, + title: m.title || m.menuName || m.menu_name, + icon: m.icon || 'orders-o', + path: m.path + })).filter((m) => m.path) +} +``` + +首页 `onShow` / `onLoad`: + +```js +import { fetchMyMobileMenus } from '../../utils/menu.js' + +async loadMenus() { + try { + this.menus = await fetchMyMobileMenus() + } catch (e) { + // 过渡:失败时仍可保留水槽测试项,避免开发卡死 + this.menus = [ + { id: 'oa-start', title: '发起审批', icon: 'edit', path: '/pages/oa/start/index' }, + { id: 'oa-form-sink', title: '表单水槽测试', icon: 'orders-o', path: '/pages/oa/form-sink/index' } + ] + } +} +``` + +正式环境以 API 为准;水槽项可不进生产角色权限。 + +- [ ] **Step 4: 验证** 角色授权后首页出现「发起审批」→ 列表 → 填写 → 提交。 + +- [ ] **Step 5: Commit** + +```bash +git add docs/sql/mobile_menu_oa_start.sql \ + backend/app/api/mini_menu.py backend/app/api/__init__.py backend/app/main.py \ + mobile-h5/utils/menu.js mobile-h5/pages/index/index.vue +git commit -m "feat: register mobile OA start menu and wire mini menus API to home" +``` + +--- + +### Task 8: 套件运行时对齐(可分期,建议第二迭代) + +**Files:** +- Create: `mobile-oa-h5/src/oa/kit-runtime.js`(从 Web `oa-form-create-*-kit.ts` 移植纯函数部分) +- Modify: `mobile-oa-h5/src/views/OaStart.vue`(watch formData 时调用 sync) + +- [ ] **Step 1: 按 zkxw-sc 已发布模板优先级移植** + +顺序:`leaveKit` 时长 → `overtimeKit`(含节假日 API 若 Web 已有)→ `tripKit` 天数 → `confirmationKit` / `resignationKit` / owner 默认值。 + +原则:只搬**纯函数**(输入 schema+formData → 新 formData),不搬 Element UI。 + +- [ ] **Step 2: 双端对照清单**(同一模板 Web vs 小程序:默认值、改开始时间后时长、提交 payload)。 + +- [ ] **Step 3: Commit** + +```bash +git add mobile-oa-h5/src/oa/kit-runtime.js mobile-oa-h5/src/views/OaStart.vue +git commit -m "feat(mobile-oa-h5): align OA kit runtime helpers with web-admin" +``` + +若排期紧:**Task 1~7 合并验收后即可开菜单**;Task 8 作为回归阻塞项,有请假/加班模板时必须完成再宣称「全量可发起」。 + +--- + +### Task 9: 端到端验收与文档勾选 + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md`(可追加阶段二验收记录) +- 可选:更新设计文档 §9 实施顺序勾选状态 + +- [ ] **Step 1: 验收清单(全部勾选才算阶段二完成)** + +| # | 项 | 通过标准 | +|---|----|----------| +| 1 | 首页菜单 | 「发起审批」来自移动端菜单授权(或明确过渡硬编码已替换) | +| 2 | 模板列表 | 已发布模板卡片可见,分组/搜索可用 | +| 3 | 打开填写 | web-view 加载 `/oa-start`,token 有效,接口 200 | +| 4 | 动态表单 | 至少 1 个含 upload/money/person 的真实模板可填 | +| 5 | 审批流 | 条件未齐 pending;填齐后 steps 出现;initiator_select 可选人 | +| 6 | 暂存 | 草稿创建成功,再开 `draftId` 可恢复 | +| 7 | 提交 | 实例创建成功,返回小程序 | +| 8 | 部署 | 测试环境 `OA_H5_ORIGIN` + 业务域名 + `/api` 反代正确 | + +- [ ] **Step 2: 跑一遍 type 统计,确认无未注册 type** + +```bash +cd backend && source venv/bin/activate +python ../scripts/oa_form_type_stats.py +``` + +- [ ] **Step 3: Commit 文档** + +```bash +git add docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md +git commit -m "docs: record mobile OA start phase-2 acceptance notes" +``` + +--- + +## 风险与对策(实施时) + +| 风险 | 对策 | +|------|------| +| 小程序 JWT 调 `/web/admin/*` 403 | 核对角色菜单码 / 数据权限;必要时加 `mini:oa:*` 或放宽运行时只读接口 | +| web-view 域名 | 测试环境必须 HTTPS 业务域名;本地用局域网 IP + 开发者工具不校验 | +| postMessage 延迟 | 提交后主动 `navigateBack`;列表 `onShow` 可刷新 | +| 套件行为不一致 | Task 8;验收时优先覆盖含 kit 的模板 | +| 菜单 SQL 与库不一致 | 以 `SysMenu` 实列为准;先 SELECT 再 INSERT | + +--- + +## Spec coverage(自检) + +| 设计 §6 要求 | 本计划任务 | +|--------------|------------| +| 小程序模板列表 | Task 5 | +| H5 正式填写 + draft/submit | Task 4 | +| flow-preview + initiator_select | Task 3 + 4 | +| 移动端菜单注册 + 首页 | Task 7 | +| 复用 OA API | Task 1 | +| 已发布可完整发起 | Task 4 + 8 + 9 | +| 不按流程单独做页 | 全程单一 `OaStart.vue` | + +--- + +## 建议执行方式 + +完成 Task 1~7 即可对内试用「发起审批」;Task 8 按真实模板缺口补齐。实现时推荐 **subagent-driven-development** 按 Task 逐个交付并人工过一下列表→提交主路径。 diff --git a/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md b/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md index 120e1386..d9a2b736 100644 --- a/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md +++ b/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md @@ -43,3 +43,13 @@ 2. 将 `OA_H5_ORIGIN` 改为手机可达地址(不要用 `127.0.0.1` 给真机) 3. 微信开发者工具可勾选「不校验合法域名」;体验版须配置业务域名 4. 小程序首页 →「表单水槽测试」 + +## 阶段二进度(2026-08-11) + +- [x] H5 运行时 API(templates / flow-preview / drafts / instances) +- [x] schema 最小预处理 + `/oa-start` 正式页 + `OaFlowPreview` +- [x] 小程序模板列表 + web-view 发起页 +- [x] `GET /api/mini/menus` + SQL 种子 `docs/sql/mobile_menu_oa_start.sql` + 首页拉菜单(失败时 fallback) +- [ ] 测试库执行 SQL 并在角色中勾选「发起审批」 +- [ ] 真机:列表 → 填写 → 草稿/提交 全链路 +- [ ] Task 8 套件运行时(请假/加班时长等)与 Web 对齐(分期) diff --git a/mobile-h5/pages/index/index.vue b/mobile-h5/pages/index/index.vue index bf5630a0..175e21f1 100644 --- a/mobile-h5/pages/index/index.vue +++ b/mobile-h5/pages/index/index.vue @@ -75,10 +75,28 @@ import MenuGrid from '../../components/MenuGrid.vue' import VanEmpty from '../../components/van-empty/van-empty.vue' import { clearSession, fetchCurrentUser, getUser } from '../../utils/auth.js' + import { fetchMyMobileMenus } from '../../utils/menu.js' const HOME_BANNER_VIDEO = 'https://video.hfsxw.cn/d04ce57416a771f180684531958c0102/db7aa1b54f8e45d3b6b240176085803f-9224e7e7aa8db3dd79de34f341d9a452-hd-nbv2.mp4' + const FALLBACK_MENUS = [ + { + id: 'oa-start', + title: '发起审批', + icon: 'edit', + path: '/pages/oa/start/index', + tone: '' + }, + { + id: 'oa-form-sink', + title: '表单水槽测试', + icon: 'orders-o', + path: '/pages/oa/form-sink/index', + tone: '' + } + ] + function applyUserToState(vm, user) { if (!user) return vm.userName = @@ -95,15 +113,7 @@ userName: '', postName: '', deptName: '', - menus: [ - { - id: 'oa-form-sink', - title: '表单水槽测试', - icon: 'orders-o', - path: '/pages/oa/form-sink/index', - tone: '' - } - ], + menus: [...FALLBACK_MENUS], banners: [ { id: 'banner-video-1', @@ -122,8 +132,21 @@ .catch(() => { /* 本地缓存已展示,忽略刷新失败 */ }) + this.loadMenus() }, methods: { + async loadMenus() { + try { + const list = await fetchMyMobileMenus() + if (list && list.length) { + this.menus = list + return + } + } catch (e) { + /* 接口未就绪或无权限时用过渡菜单 */ + } + this.menus = [...FALLBACK_MENUS] + }, onMenuSelect(m) { if (m.path) { uni.navigateTo({ url: m.path }) diff --git a/mobile-h5/utils/menu.js b/mobile-h5/utils/menu.js new file mode 100644 index 00000000..9745cbb1 --- /dev/null +++ b/mobile-h5/utils/menu.js @@ -0,0 +1,22 @@ +import { get } from './request.js' +import { getToken } from './auth.js' + +function authHeader() { + const token = getToken() + return token ? { Authorization: 'Bearer ' + token } : {} +} + +/** 当前用户移动端菜单(扁平列表) */ +export async function fetchMyMobileMenus() { + const data = await get('/mini/menus', authHeader()) + const list = data?.list || data || [] + return list + .map((m) => ({ + id: m.id, + title: m.title || m.menuName || m.menu_name, + icon: m.icon || 'edit', + path: m.path, + tone: '' + })) + .filter((m) => m.path) +}