|
@@ -0,0 +1,837 @@
|
|
|
|
|
+# 移动端发起审批 · 阶段一(水槽 Spike)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:** 搭好独立 OA 表单 H5(`@form-create/vant`)与小程序整页 web-view 入口,用「按 zkxw-sc 真实用量」的厨房水槽 rule 在微信真机验证渲染与交互。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** 小程序首页硬编码测试菜单 → `/pages/oa/form-sink/index` 整页 `web-view` → 独立工程 `mobile-oa-h5` 的 `/oa-form-sink`。水槽 type 以测试库已发布 `form_schema_json` 统计为准(P0 先通),不为未使用 type 空转。正式模板列表 / 草稿提交 / 菜单注册见后续阶段二计划。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Vue3、Vite、Vant4、`@form-create/vant`、uni-app(HBuilderX)微信小程序、`web-view`
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-11-mobile-oa-start-approval-design.md`
|
|
|
|
|
+
|
|
|
|
|
+**Out of scope (本计划):** 正式发起 API、flow-preview 真联调、移动端菜单后台注册、全量套件精修(P2 可先 stub 占位)。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## File map
|
|
|
|
|
+
|
|
|
|
|
+| 路径 | 职责 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| `scripts/oa_form_type_stats.py` | 统计 `zkxw-sc` 已发布模板中的 `type` 频率 |
|
|
|
|
|
+| `mobile-oa-h5/package.json` | H5 依赖与脚本 |
|
|
|
|
|
+| `mobile-oa-h5/vite.config.js` | Vite + `@` 别名 + 开发 server |
|
|
|
|
|
+| `mobile-oa-h5/index.html` | H5 入口 HTML |
|
|
|
|
|
+| `mobile-oa-h5/src/main.js` | 挂载 Vue、Vant、form-create/vant、自研 type 注册 |
|
|
|
|
|
+| `mobile-oa-h5/src/App.vue` | 路由出口 |
|
|
|
|
|
+| `mobile-oa-h5/src/router.js` | `/oa-form-sink`、`/oa-start`(后者可占位) |
|
|
|
|
|
+| `mobile-oa-h5/src/styles/tokens.css` | 三墙 Token(`#0a9f6d` 等) |
|
|
|
|
|
+| `mobile-oa-h5/src/oa/register-components.js` | 注册自研 type(与 Web 同名) |
|
|
|
|
|
+| `mobile-oa-h5/src/oa/kitchen-sink-rule.js` | 水槽 rule(按库内实际 type) |
|
|
|
|
|
+| `mobile-oa-h5/src/oa/components/*.vue` | 自研控件 stub / 初版(P0 可用) |
|
|
|
|
|
+| `mobile-oa-h5/src/views/FormSink.vue` | 水槽页:form-create + 底栏校验/导出 |
|
|
|
|
|
+| `mobile-oa-h5/.env.development` | `VITE_API_BASE` 等 |
|
|
|
|
|
+| `mobile-h5/utils/oa-h5.js` | H5 水槽 / 发起页 URL 拼装 |
|
|
|
|
|
+| `mobile-h5/pages/oa/form-sink/index.vue` | 整页 web-view |
|
|
|
|
|
+| `mobile-h5/pages/index/index.vue` | 注入测试菜单 |
|
|
|
|
|
+| `mobile-h5/pages.json` | 注册 form-sink 路由 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: 固化 type 统计脚本
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `scripts/oa_form_type_stats.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写入统计脚本**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""统计 zkxw-sc(或当前 DATABASE_URL)已发布 OA 模板 form_schema 中的 type。"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+import sys
|
|
|
|
|
+from collections import Counter
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
+sys.path.insert(0, str(ROOT / "backend"))
|
|
|
|
|
+
|
|
|
|
|
+from sqlalchemy import create_engine, text # noqa: E402
|
|
|
|
|
+from app.core.config import settings # noqa: E402
|
|
|
|
|
+
|
|
|
|
|
+CUSTOM = {
|
|
|
|
|
+ "money",
|
|
|
|
|
+ "person",
|
|
|
|
|
+ "ownerPerson",
|
|
|
|
|
+ "dept",
|
|
|
|
|
+ "ownerDept",
|
|
|
|
|
+ "post",
|
|
|
|
|
+ "receiveAccount",
|
|
|
|
|
+ "relatedApproval",
|
|
|
|
|
+ "upload",
|
|
|
|
|
+ "fcUpload",
|
|
|
|
|
+ "leaveKit",
|
|
|
|
|
+ "overtimeKit",
|
|
|
|
|
+ "confirmationKit",
|
|
|
|
|
+ "resignationKit",
|
|
|
|
|
+ "tripKit",
|
|
|
|
|
+ "tripItinerary",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def walk_types(node, out: list) -> None:
|
|
|
|
|
+ if node is None:
|
|
|
|
|
+ return
|
|
|
|
|
+ if isinstance(node, dict):
|
|
|
|
|
+ t = node.get("type")
|
|
|
|
|
+ if isinstance(t, str) and t.strip():
|
|
|
|
|
+ out.append(t.strip())
|
|
|
|
|
+ for v in node.values():
|
|
|
|
|
+ if isinstance(v, (dict, list)):
|
|
|
|
|
+ walk_types(v, out)
|
|
|
|
|
+ elif isinstance(node, list):
|
|
|
|
|
+ for item in node:
|
|
|
|
|
+ walk_types(item, out)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main() -> None:
|
|
|
|
|
+ engine = create_engine(settings.DATABASE_URL)
|
|
|
|
|
+ sql = text(
|
|
|
|
|
+ """
|
|
|
|
|
+ SELECT a.app_name, v.form_schema_json
|
|
|
|
|
+ FROM oa_app a
|
|
|
|
|
+ JOIN oa_app_version v ON v.app_id = a.id
|
|
|
|
|
+ WHERE a.del_flag = 0 AND v.del_flag = 0
|
|
|
|
|
+ AND v.status = 'published'
|
|
|
|
|
+ AND (v.is_latest_published = 1 OR v.is_latest_published = '1')
|
|
|
|
|
+ ORDER BY a.app_name
|
|
|
|
|
+ """
|
|
|
|
|
+ )
|
|
|
|
|
+ counter: Counter[str] = Counter()
|
|
|
|
|
+ with engine.connect() as conn:
|
|
|
|
|
+ rows = conn.execute(sql).mappings().all()
|
|
|
|
|
+ print(f"published_latest_apps={len(rows)}")
|
|
|
|
|
+ for row in rows:
|
|
|
|
|
+ raw = row["form_schema_json"] or ""
|
|
|
|
|
+ try:
|
|
|
|
|
+ schema = json.loads(raw) if raw else {}
|
|
|
|
|
+ except json.JSONDecodeError:
|
|
|
|
|
+ print("BAD_JSON", row["app_name"])
|
|
|
|
|
+ continue
|
|
|
|
|
+ types: list[str] = []
|
|
|
|
|
+ if isinstance(schema, list):
|
|
|
|
|
+ walk_types(schema, types)
|
|
|
|
|
+ else:
|
|
|
|
|
+ walk_types(schema.get("rule", schema), types)
|
|
|
|
|
+ for t in types:
|
|
|
|
|
+ counter[t] += 1
|
|
|
|
|
+ print("=== frequency ===")
|
|
|
|
|
+ for t, c in counter.most_common():
|
|
|
|
|
+ kind = "CUSTOM" if t in CUSTOM else "base"
|
|
|
|
|
+ print(f"{c:4d} {kind:6s} {t}")
|
|
|
|
|
+ print("=== custom used ===")
|
|
|
|
|
+ print(sorted(set(counter) & CUSTOM))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行脚本确认与设计文档一致**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python ../scripts/oa_form_type_stats.py
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: `published_latest_apps=27`(或接近),`upload`/`money`/`relatedApproval`/`receiveAccount` 排在自研前列。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add scripts/oa_form_type_stats.py
|
|
|
|
|
+git commit -m "chore: add OA published form type stats script against zkxw-sc"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: 初始化 `mobile-oa-h5` 工程
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `mobile-oa-h5/package.json`
|
|
|
|
|
+- Create: `mobile-oa-h5/vite.config.js`
|
|
|
|
|
+- Create: `mobile-oa-h5/index.html`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/main.js`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/App.vue`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/router.js`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/styles/tokens.css`
|
|
|
|
|
+- Create: `mobile-oa-h5/.gitignore`
|
|
|
|
|
+- Create: `mobile-oa-h5/.env.development`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写入 package.json**
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "mobile-oa-h5",
|
|
|
|
|
+ "version": "0.1.0",
|
|
|
|
|
+ "private": true,
|
|
|
|
|
+ "type": "module",
|
|
|
|
|
+ "scripts": {
|
|
|
|
|
+ "dev": "vite",
|
|
|
|
|
+ "build": "vite build",
|
|
|
|
|
+ "preview": "vite preview"
|
|
|
|
|
+ },
|
|
|
|
|
+ "dependencies": {
|
|
|
|
|
+ "@form-create/vant": "^3.2.14",
|
|
|
|
|
+ "vant": "^4.9.21",
|
|
|
|
|
+ "vue": "^3.5.13",
|
|
|
|
|
+ "vue-router": "^4.5.0"
|
|
|
|
|
+ },
|
|
|
|
|
+ "devDependencies": {
|
|
|
|
|
+ "@vitejs/plugin-vue": "^5.2.1",
|
|
|
|
|
+ "vite": "^6.0.7"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(若 `@form-create/vant` 版本解析失败,以 `npm view @form-create/vant version` 的 next/最新 Vue3 线为准。)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 写入 vite.config.js / index.html / 入口**
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/vite.config.js`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { defineConfig } from 'vite'
|
|
|
|
|
+import vue from '@vitejs/plugin-vue'
|
|
|
|
|
+import { fileURLToPath, URL } from 'node:url'
|
|
|
|
|
+
|
|
|
|
|
+export default defineConfig({
|
|
|
|
|
+ plugins: [vue()],
|
|
|
|
|
+ base: '/oa-h5/',
|
|
|
|
|
+ resolve: {
|
|
|
|
|
+ alias: {
|
|
|
|
|
+ '@': fileURLToPath(new URL('./src', import.meta.url))
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ server: {
|
|
|
|
|
+ host: '0.0.0.0',
|
|
|
|
|
+ port: 5178,
|
|
|
|
|
+ proxy: {
|
|
|
|
|
+ '/api': {
|
|
|
|
|
+ target: 'http://127.0.0.1:8000',
|
|
|
|
|
+ changeOrigin: true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/index.html`:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+<!doctype html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+ <head>
|
|
|
|
|
+ <meta charset="UTF-8" />
|
|
|
|
|
+ <meta
|
|
|
|
|
+ name="viewport"
|
|
|
|
|
+ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
|
|
|
|
+ />
|
|
|
|
|
+ <title>中科兴皖 · OA 表单</title>
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <body>
|
|
|
|
|
+ <div id="app"></div>
|
|
|
|
|
+ <script type="module" src="/src/main.js"></script>
|
|
|
|
|
+ </body>
|
|
|
|
|
+</html>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/styles/tokens.css`:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+:root {
|
|
|
|
|
+ --brand-green: #0a9f6d;
|
|
|
|
|
+ --brand-green-dark: #087f57;
|
|
|
|
|
+ --brand-green-soft: #e9f9f2;
|
|
|
|
|
+ --bg-page: #f5f8f7;
|
|
|
|
|
+ --text-primary: #1f2a26;
|
|
|
|
|
+ --text-muted: #6b7b74;
|
|
|
|
|
+ --border: #dcebe4;
|
|
|
|
|
+}
|
|
|
|
|
+body {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ background: var(--bg-page);
|
|
|
|
|
+ color: var(--text-primary);
|
|
|
|
|
+ font-family: system-ui, -apple-system, 'PingFang SC', sans-serif;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/App.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <router-view />
|
|
|
|
|
+</template>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/router.js`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { createRouter, createWebHistory } from 'vue-router'
|
|
|
|
|
+import FormSink from './views/FormSink.vue'
|
|
|
|
|
+
|
|
|
|
|
+export default createRouter({
|
|
|
|
|
+ history: createWebHistory(import.meta.env.BASE_URL),
|
|
|
|
|
+ routes: [
|
|
|
|
|
+ { path: '/', redirect: '/oa-form-sink' },
|
|
|
|
|
+ { path: '/oa-form-sink', component: FormSink },
|
|
|
|
|
+ {
|
|
|
|
|
+ path: '/oa-start',
|
|
|
|
|
+ component: {
|
|
|
|
|
+ template: '<div style="padding:16px">oa-start 占位(阶段二)</div>'
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/main.js`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { createApp } from 'vue'
|
|
|
|
|
+import Vant from 'vant'
|
|
|
|
|
+import 'vant/lib/index.css'
|
|
|
|
|
+import formCreateMobile from '@form-create/vant'
|
|
|
|
|
+import App from './App.vue'
|
|
|
|
|
+import router from './router'
|
|
|
|
|
+import './styles/tokens.css'
|
|
|
|
|
+import { registerOaComponents } from './oa/register-components'
|
|
|
|
|
+
|
|
|
|
|
+const app = createApp(App)
|
|
|
|
|
+app.use(router)
|
|
|
|
|
+app.use(Vant)
|
|
|
|
|
+app.use(formCreateMobile)
|
|
|
|
|
+registerOaComponents(formCreateMobile)
|
|
|
|
|
+app.mount('#app')
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/.gitignore`:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+node_modules/
|
|
|
|
|
+dist/
|
|
|
|
|
+.DS_Store
|
|
|
|
|
+.env.local
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/.env.development`:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+VITE_API_BASE=/api
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 安装依赖**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && npm install
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: 无报错,存在 `node_modules/@form-create/vant` 与 `node_modules/vant`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add mobile-oa-h5
|
|
|
|
|
+git commit -m "chore: scaffold mobile-oa-h5 with Vite, Vant and form-create"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: 水槽 rule + 自研 type 注册(P0 可用,P2 stub)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `mobile-oa-h5/src/oa/kitchen-sink-rule.js`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/oa/register-components.js`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/oa/components/OaStubField.vue`
|
|
|
|
|
+- Create: `mobile-oa-h5/src/views/FormSink.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Stub 通用组件**
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/oa/components/OaStubField.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="oa-stub">
|
|
|
|
|
+ <div class="oa-stub__title">{{ title || typeName }}</div>
|
|
|
|
|
+ <van-field
|
|
|
|
|
+ v-model="inner"
|
|
|
|
|
+ :placeholder="'[' + typeName + '] 占位,后续替换真实交互'"
|
|
|
|
|
+ @update:model-value="onInput"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { computed, ref, watch } from 'vue'
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps({
|
|
|
|
|
+ modelValue: { type: [String, Number, Array, Object], default: '' },
|
|
|
|
|
+ formCreateInject: { type: Object, default: null }
|
|
|
|
|
+})
|
|
|
|
|
+const emit = defineEmits(['update:modelValue'])
|
|
|
|
|
+
|
|
|
|
|
+const typeName = computed(
|
|
|
|
|
+ () => props.formCreateInject?.rule?.type || 'unknown'
|
|
|
|
|
+)
|
|
|
|
|
+const title = computed(() => props.formCreateInject?.rule?.title || '')
|
|
|
|
|
+const inner = ref(
|
|
|
|
|
+ typeof props.modelValue === 'string' || typeof props.modelValue === 'number'
|
|
|
|
|
+ ? String(props.modelValue ?? '')
|
|
|
|
|
+ : JSON.stringify(props.modelValue ?? '')
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => props.modelValue,
|
|
|
|
|
+ (v) => {
|
|
|
|
|
+ inner.value =
|
|
|
|
|
+ typeof v === 'string' || typeof v === 'number'
|
|
|
|
|
+ ? String(v ?? '')
|
|
|
|
|
+ : JSON.stringify(v ?? '')
|
|
|
|
|
+ }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+function onInput(v) {
|
|
|
|
|
+ emit('update:modelValue', v)
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.oa-stub {
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.oa-stub__title {
|
|
|
|
|
+ margin: 0 0 6px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 注册表(type 名与 Web 一致)**
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/oa/register-components.js`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import OaStubField from './components/OaStubField.vue'
|
|
|
|
|
+
|
|
|
|
|
+/** 与 web-admin formCreate.component 名称对齐;阶段一可用 stub */
|
|
|
|
|
+const CUSTOM_TYPES = [
|
|
|
|
|
+ 'upload',
|
|
|
|
|
+ 'money',
|
|
|
|
|
+ 'relatedApproval',
|
|
|
|
|
+ 'receiveAccount',
|
|
|
|
|
+ 'dept',
|
|
|
|
|
+ 'person',
|
|
|
|
|
+ 'ownerPerson',
|
|
|
|
|
+ 'ownerDept',
|
|
|
|
|
+ 'post',
|
|
|
|
|
+ 'leaveKit',
|
|
|
|
|
+ 'overtimeKit',
|
|
|
|
|
+ 'confirmationKit',
|
|
|
|
|
+ 'resignationKit',
|
|
|
|
|
+ 'tripKit',
|
|
|
|
|
+ 'tripItinerary'
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+export function registerOaComponents(formCreate) {
|
|
|
|
|
+ if (!formCreate || typeof formCreate.component !== 'function') return
|
|
|
|
|
+ for (const name of CUSTOM_TYPES) {
|
|
|
|
|
+ formCreate.component(name, OaStubField)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: kitchen-sink-rule(覆盖库内出现过的 type)**
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/oa/kitchen-sink-rule.js`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+/** 按 zkxw-sc 已发布模板实际出现的 type 构造;非设计器理论全集 */
|
|
|
|
|
+export const kitchenSinkRule = [
|
|
|
|
|
+ { type: 'input', field: 'title', title: '单行文本', value: '', $required: true },
|
|
|
|
|
+ { type: 'textarea', field: 'remark', title: '多行文本', value: '' },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'select',
|
|
|
|
|
+ field: 'level',
|
|
|
|
|
+ title: '下拉',
|
|
|
|
|
+ value: '',
|
|
|
|
|
+ options: [
|
|
|
|
|
+ { label: '普通', value: 'normal' },
|
|
|
|
|
+ { label: '紧急', value: 'urgent' }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ { type: 'inputNumber', field: 'qty', title: '数字', value: 1 },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'datePicker',
|
|
|
|
|
+ field: 'bizDate',
|
|
|
|
|
+ title: '日期',
|
|
|
|
|
+ value: '',
|
|
|
|
|
+ props: { type: 'date' }
|
|
|
|
|
+ },
|
|
|
|
|
+ { type: 'upload', field: 'files', title: '附件 upload', value: [] },
|
|
|
|
|
+ { type: 'money', field: 'amount', title: '金额 money', value: '' },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'relatedApproval',
|
|
|
|
|
+ field: 'related',
|
|
|
|
|
+ title: '关联审批 relatedApproval',
|
|
|
|
|
+ value: []
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'receiveAccount',
|
|
|
|
|
+ field: 'payAccount',
|
|
|
|
|
+ title: '收款账户 receiveAccount',
|
|
|
|
|
+ value: []
|
|
|
|
|
+ },
|
|
|
|
|
+ { type: 'dept', field: 'deptId', title: '部门 dept', value: '' },
|
|
|
|
|
+ { type: 'person', field: 'personIds', title: '人员 person', value: [] },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'ownerPerson',
|
|
|
|
|
+ field: 'ownerPerson',
|
|
|
|
|
+ title: '所属人 ownerPerson',
|
|
|
|
|
+ value: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'ownerDept',
|
|
|
|
|
+ field: 'ownerDept',
|
|
|
|
|
+ title: '所属部门 ownerDept',
|
|
|
|
|
+ value: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ { type: 'post', field: 'postId', title: '岗位 post', value: '' },
|
|
|
|
|
+ { type: 'leaveKit', field: 'leaveKit', title: '请假套件 leaveKit', value: {} },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'overtimeKit',
|
|
|
|
|
+ field: 'overtimeKit',
|
|
|
|
|
+ title: '加班套件 overtimeKit',
|
|
|
|
|
+ value: {}
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'confirmationKit',
|
|
|
|
|
+ field: 'confirmationKit',
|
|
|
|
|
+ title: '转正套件 confirmationKit',
|
|
|
|
|
+ value: {}
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'resignationKit',
|
|
|
|
|
+ field: 'resignationKit',
|
|
|
|
|
+ title: '离职套件 resignationKit',
|
|
|
|
|
+ value: {}
|
|
|
|
|
+ },
|
|
|
|
|
+ { type: 'tripKit', field: 'tripKit', title: '差旅套件 tripKit', value: {} },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'tripItinerary',
|
|
|
|
|
+ field: 'tripItinerary',
|
|
|
|
|
+ title: '行程 tripItinerary',
|
|
|
|
|
+ value: []
|
|
|
|
|
+ }
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+export const kitchenSinkOption = {
|
|
|
|
|
+ submitBtn: false,
|
|
|
|
|
+ resetBtn: false,
|
|
|
|
|
+ form: { labelPosition: 'top' }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: FormSink 页面**
|
|
|
|
|
+
|
|
|
|
|
+`mobile-oa-h5/src/views/FormSink.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="sink">
|
|
|
|
|
+ <header class="sink__hd">表单水槽测试</header>
|
|
|
|
|
+ <div class="sink__body">
|
|
|
|
|
+ <form-create-mobile
|
|
|
|
|
+ v-model="formData"
|
|
|
|
|
+ v-model:api="fapi"
|
|
|
|
|
+ :rule="rule"
|
|
|
|
|
+ :option="option"
|
|
|
|
|
+ />
|
|
|
|
|
+ <pre class="sink__json">{{ preview }}</pre>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <footer class="sink__ft">
|
|
|
|
|
+ <van-button block type="primary" @click="onValidate">校验并导出</van-button>
|
|
|
|
|
+ </footer>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { computed, ref } from 'vue'
|
|
|
|
|
+import { showToast } from 'vant'
|
|
|
|
|
+import { kitchenSinkOption, kitchenSinkRule } from '../oa/kitchen-sink-rule'
|
|
|
|
|
+
|
|
|
|
|
+const formData = ref({})
|
|
|
|
|
+const fapi = ref(null)
|
|
|
|
|
+const rule = ref(kitchenSinkRule)
|
|
|
|
|
+const option = ref(kitchenSinkOption)
|
|
|
|
|
+const preview = computed(() => JSON.stringify(formData.value, null, 2))
|
|
|
|
|
+
|
|
|
|
|
+async function onValidate() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fapi.value?.validate?.()
|
|
|
|
|
+ showToast('校验通过')
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ showToast(e?.message || '校验失败')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.sink {
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ background: var(--bg-page);
|
|
|
|
|
+}
|
|
|
|
|
+.sink__hd {
|
|
|
|
|
+ padding: 14px 16px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: linear-gradient(135deg, var(--brand-green), var(--brand-green-dark));
|
|
|
|
|
+}
|
|
|
|
|
+.sink__body {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ padding: 12px 16px 88px;
|
|
|
|
|
+}
|
|
|
|
|
+.sink__json {
|
|
|
|
|
+ margin-top: 16px;
|
|
|
|
|
+ padding: 10px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.sink__ft {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-top: 1px solid var(--border);
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 本地浏览器验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && npm run dev
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: 打开 `http://127.0.0.1:5178/oa-h5/oa-form-sink`,基础控件与自研 stub 均可见;点「校验并导出」可更新 JSON。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add mobile-oa-h5/src
|
|
|
|
|
+git commit -m "feat(mobile-oa-h5): add kitchen-sink form page with OA type stubs"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: 小程序测试菜单 + 整页 web-view
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `mobile-h5/utils/oa-h5.js`
|
|
|
|
|
+- Create: `mobile-h5/pages/oa/form-sink/index.vue`
|
|
|
|
|
+- Modify: `mobile-h5/pages.json`
|
|
|
|
|
+- Modify: `mobile-h5/pages/index/index.vue`
|
|
|
|
|
+- Modify: `mobile-h5/utils/config.js`(增加 `OA_H5_ORIGIN`)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 配置 H5 源**
|
|
|
|
|
+
|
|
|
|
|
+在 `mobile-h5/utils/config.js` 追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+/**
|
|
|
|
|
+ * OA 表单 H5 源(web-view)。
|
|
|
|
|
+ * 本地:电脑局域网 IP + Vite 端口,须与手机同网;真机/体验版改为已配置业务域名。
|
|
|
|
|
+ * 例:http://192.168.1.8:5178/oa-h5/
|
|
|
|
|
+ */
|
|
|
|
|
+export const OA_H5_ORIGIN = 'http://127.0.0.1:5178/oa-h5'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`mobile-h5/utils/oa-h5.js`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { OA_H5_ORIGIN } from './config.js'
|
|
|
|
|
+import { getToken } from './auth.js'
|
|
|
|
|
+
|
|
|
|
|
+function trimSlash(s) {
|
|
|
|
|
+ return String(s || '').replace(/\/$/, '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 水槽页 URL */
|
|
|
|
|
+export function buildFormSinkUrl() {
|
|
|
|
|
+ const base = trimSlash(OA_H5_ORIGIN)
|
|
|
|
|
+ const token = encodeURIComponent(getToken() || '')
|
|
|
|
|
+ return `${base}/oa-form-sink?token=${token}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 正式发起页 URL(阶段二) */
|
|
|
|
|
+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: web-view 页**
|
|
|
|
|
+
|
|
|
|
|
+`mobile-h5/pages/oa/form-sink/index.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <web-view v-if="src" :src="src" @message="onMessage" />
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+ import { buildFormSinkUrl } from '../../../utils/oa-h5.js'
|
|
|
|
|
+
|
|
|
|
|
+ export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return { src: '' }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad() {
|
|
|
|
|
+ this.src = buildFormSinkUrl()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ onMessage(e) {
|
|
|
|
|
+ console.log('oa-h5 message', e && e.detail)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+</script>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 注册路由**
|
|
|
|
|
+
|
|
|
|
|
+在 `mobile-h5/pages.json` 的 `pages` 数组中增加:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "path": "pages/oa/form-sink/index",
|
|
|
|
|
+ "style": {
|
|
|
|
|
+ "navigationBarTitleText": "表单水槽测试",
|
|
|
|
|
+ "navigationBarBackgroundColor": "#0a9f6d",
|
|
|
|
|
+ "navigationBarTextStyle": "white"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 首页注入测试菜单**
|
|
|
|
|
+
|
|
|
|
|
+修改 `mobile-h5/pages/index/index.vue` 的 `data()`,将 `menus: []` 改为至少包含:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+menus: [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'oa-form-sink',
|
|
|
|
|
+ title: '表单水槽测试',
|
|
|
|
|
+ icon: 'orders-o',
|
|
|
|
|
+ path: '/pages/oa/form-sink/index',
|
|
|
|
|
+ tone: ''
|
|
|
|
|
+ }
|
|
|
|
|
+]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(`onShow` 暂不覆盖该数组;阶段二接真实菜单时再合并/替换。)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 真机 / 开发者工具验收清单**
|
|
|
|
|
+
|
|
|
|
|
+1. `mobile-oa-h5`:`npm run dev`,`OA_H5_ORIGIN` 改为手机可达地址(非 `127.0.0.1`)。
|
|
|
|
|
+2. 微信公众平台配置业务域名(体验版);开发工具可勾选不校验合法域名。
|
|
|
|
|
+3. 打开小程序首页 → 点「表单水槽测试」→ web-view 加载水槽。
|
|
|
|
|
+4. 确认:无白屏;基础字段可填;自研 stub 可见;校验按钮可用。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add mobile-h5/utils/config.js mobile-h5/utils/oa-h5.js \
|
|
|
|
|
+ mobile-h5/pages/oa/form-sink/index.vue mobile-h5/pages.json \
|
|
|
|
|
+ mobile-h5/pages/index/index.vue
|
|
|
|
|
+git commit -m "feat(mobile-h5): add OA form-sink web-view entry and test menu"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: 记录 Spike 结论(为阶段二铺路)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md`(实施当天日期可改)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 按模板写结论**
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+# OA 表单水槽 Spike 记录
|
|
|
|
|
+
|
|
|
|
|
+- 日期:
|
|
|
|
|
+- 环境:微信开发者工具 / 真机型号 /
|
|
|
|
|
+- H5 地址:
|
|
|
|
|
+- 结果:通过 / 阻塞
|
|
|
|
|
+- 问题列表:
|
|
|
|
|
+ 1.
|
|
|
|
|
+- 下一步:开始 P0 真实控件(upload/money/relatedApproval/receiveAccount)移植计划
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit notes**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md
|
|
|
|
|
+git commit -m "docs: record mobile OA form-sink spike results"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 阶段二预告(另开计划,本计划不实施)
|
|
|
|
|
+
|
|
|
|
|
+1. P0 控件真实实现(对照 Web:`upload` / `money` / `relatedApproval` / `receiveAccount`)。
|
|
|
|
|
+2. H5 `/oa-start` + templates / flow-preview / drafts / instances。
|
|
|
|
|
+3. 小程序原生模板列表页。
|
|
|
|
|
+4. 移动端菜单注册 + 首页菜单 API。
|
|
|
|
|
+5. P1/P2 控件与套件 + 按已发布模板回归。
|
|
|
|
|
+
|
|
|
|
|
+**zkxw-sc 用量优先级(2026-08-11):**
|
|
|
|
|
+P0 `upload` > `money` > `relatedApproval` > `receiveAccount`;P1 `dept`/`person`;P2 各套件与 `owner*`/`post`。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Spec coverage(自检)
|
|
|
|
|
+
|
|
|
|
|
+| 设计要求 | 本计划任务 |
|
|
|
|
|
+|----------|------------|
|
|
|
|
|
+| 独立 H5 + `@form-create/vant` | Task 2–3 |
|
|
|
|
|
+| 水槽按 zkxw-sc 真实 type | Task 1、Task 3 rule |
|
|
|
|
|
+| 小程序测试菜单 + 整页 web-view | Task 4 |
|
|
|
|
|
+| 一套 JSON 渲染、不按流程单独做页 | 架构 + 阶段二 `/oa-start` 占位 |
|
|
|
|
|
+| 正式列表/提交/菜单 | 明确 out of scope → 阶段二 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 执行方式
|
|
|
|
|
+
|
|
|
|
|
+Plan complete. Two execution options:
|
|
|
|
|
+
|
|
|
|
|
+1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间评审
|
|
|
|
|
+2. **Inline Execution** — 本会话按 executing-plans 连续做并设检查点
|
|
|
|
|
+
|
|
|
|
|
+Which approach?
|