|
@@ -0,0 +1,630 @@
|
|
|
|
|
+# OA Trip Kit(出差套件)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:** 在审批「套件」Tab 增加出差套件:事由 + 可增删多行程(钉钉半日制算时长)+ 出差天数汇总 / 备注 / 同行人 / 外部人员;提交时后端权威重算与校验;不回写业务表。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** FormCreate `tripKit` 布局容器(`input: false`,默认 6 子字段)+ 自定义可填控件 `tripItinerary`(`v-model` 为行程对象数组)。半日时长纯函数前后端各一份;发起页 watch 行程/天数;`start_instance` 调用 `apply_trip_kit_days`。手改标记:行内 `durationManual`;出差天数用 `formData['__manual_' + daysField] = true`(对齐请假套件)。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Vue 3 · `@form-create/designer` · Element Plus · FastAPI · pytest · Node `node:test`
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## File map
|
|
|
|
|
+
|
|
|
|
|
+| Path | Responsibility |
|
|
|
|
|
+|------|----------------|
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-trip-days.ts` | 半日制时长 / 汇总纯函数 |
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-trip-days.test.ts` | 算法单测 |
|
|
|
|
|
+| Create: `backend/app/services/oa_trip_days.py` | 同算法 + apply/validate |
|
|
|
|
|
+| Create: `backend/tests/test_oa_trip_days.py` | pytest |
|
|
|
|
|
+| Create: `web-admin/src/components/oa-approval/OaTripItinerary.vue` | 行程列表 UI(增删、半日联动) |
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-trip-itinerary-rule.ts` | `tripItinerary` DragRule(一般不单独出现在组件库;由套件内嵌) |
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-form-create-trip-itinerary.ts` | 注册 `tripItinerary` 控件 |
|
|
|
|
|
+| Create: `web-admin/src/components/oa-approval/OaTripKit.vue` | 套件容器 |
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-trip-kit-rule.ts` | 套件 DragRule + 默认子字段 |
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-form-create-trip-kit.ts` | `setupOaTripKit` |
|
|
|
|
|
+| Create: `web-admin/src/utils/oa-trip-kit-runtime.ts` | 解析 schema、apply 自动时长/天数 |
|
|
|
|
|
+| Modify: `web-admin/src/main.ts` | 注册 itinerary + tripKit |
|
|
|
|
|
+| Modify: `web-admin/src/oa-fc-host/main.ts` | 同上 |
|
|
|
|
|
+| Modify: `web-admin/src/oa-fc-host/App.vue` | 拖入 `tripKit` 画布 refresh 白名单 |
|
|
|
|
|
+| Modify: `web-admin/src/views/oa-approval/start/index.vue` | 发起页联动 |
|
|
|
|
|
+| Modify: `web-admin/src/utils/oa-approval-print.ts` | unwrap `tripKit`;格式化行程数组 |
|
|
|
|
|
+| Modify: `backend/app/services/oa_publish.py` | `LAYOUT_TYPES` + `FILLABLE_TYPES` |
|
|
|
|
|
+| Modify: `backend/app/services/oa_engine.py` | `start_instance` 调用 apply/validate |
|
|
|
|
|
+| Modify: `docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md` | 状态 → 已实现 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: Half-day algorithm (TDD, frontend)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-trip-days.ts`
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-trip-days.test.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write failing tests**
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+// web-admin/src/utils/oa-trip-days.test.ts
|
|
|
|
|
+import { describe, it } from 'node:test'
|
|
|
|
|
+import assert from 'node:assert/strict'
|
|
|
|
|
+import {
|
|
|
|
|
+ calcTripLegDays,
|
|
|
|
|
+ sumTripLegDays,
|
|
|
|
|
+ isHalfDayMultiple
|
|
|
|
|
+} from './oa-trip-days'
|
|
|
|
|
+
|
|
|
|
|
+describe('calcTripLegDays', () => {
|
|
|
|
|
+ it('same day morning->morning => 0.5', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('2026-08-01', '上午', '2026-08-01', '上午'), 0.5)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('same day morning->afternoon => 1', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('2026-08-01', '上午', '2026-08-01', '下午'), 1)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('same day afternoon->afternoon => 0.5', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('2026-08-01', '下午', '2026-08-01', '下午'), 0.5)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('same day afternoon->morning => null', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('2026-08-01', '下午', '2026-08-01', '上午'), null)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('D1 morning -> D2 afternoon => 2', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('2026-08-01', '上午', '2026-08-02', '下午'), 2)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('D1 afternoon -> D2 morning => 1', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('2026-08-01', '下午', '2026-08-02', '上午'), 1)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('invalid date => null', () => {
|
|
|
|
|
+ assert.equal(calcTripLegDays('', '上午', '2026-08-01', '下午'), null)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe('sumTripLegDays', () => {
|
|
|
|
|
+ it('sums durations', () => {
|
|
|
|
|
+ assert.equal(sumTripLegDays([{ duration: 0.5 }, { duration: 1 }, { duration: 2 }]), 3.5)
|
|
|
|
|
+ })
|
|
|
|
|
+ it('ignores non-number', () => {
|
|
|
|
|
+ assert.equal(sumTripLegDays([{ duration: 1 }, { duration: null }]), 1)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe('isHalfDayMultiple', () => {
|
|
|
|
|
+ it('accepts 0.5 steps', () => {
|
|
|
|
|
+ assert.equal(isHalfDayMultiple(1.5), true)
|
|
|
|
|
+ assert.equal(isHalfDayMultiple(1.25), false)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run — expect FAIL**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd web-admin && pnpm exec tsx --test src/utils/oa-trip-days.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: FAIL(模块不存在)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Implement `oa-trip-days.ts`**
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+// web-admin/src/utils/oa-trip-days.ts
|
|
|
|
|
+const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
|
|
|
+
|
|
|
|
|
+export type TripHalf = '上午' | '下午'
|
|
|
|
|
+
|
|
|
|
|
+function dayIndex(dateStr: string): number | null {
|
|
|
|
|
+ const m = DATE_RE.exec(String(dateStr || '').trim())
|
|
|
|
|
+ if (!m) return null
|
|
|
|
|
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3])
|
|
|
|
|
+ const t = Date.UTC(y, mo - 1, d)
|
|
|
|
|
+ if (Number.isNaN(t)) return null
|
|
|
|
|
+ return t / 86400000
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function startOffset(half: string): number | null {
|
|
|
|
|
+ if (half === '上午') return 0
|
|
|
|
|
+ if (half === '下午') return 0.5
|
|
|
|
|
+ return null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function endOffset(half: string): number | null {
|
|
|
|
|
+ if (half === '上午') return 0.5
|
|
|
|
|
+ if (half === '下午') return 1
|
|
|
|
|
+ return null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 非法或 endPoint <= startPoint 返回 null */
|
|
|
|
|
+export function calcTripLegDays(
|
|
|
|
|
+ startDate: string,
|
|
|
|
|
+ startHalf: string,
|
|
|
|
|
+ endDate: string,
|
|
|
|
|
+ endHalf: string
|
|
|
|
|
+): number | null {
|
|
|
|
|
+ const ds = dayIndex(startDate)
|
|
|
|
|
+ const de = dayIndex(endDate)
|
|
|
|
|
+ const so = startOffset(String(startHalf || '').trim())
|
|
|
|
|
+ const eo = endOffset(String(endHalf || '').trim())
|
|
|
|
|
+ if (ds == null || de == null || so == null || eo == null) return null
|
|
|
|
|
+ const duration = de + eo - (ds + so)
|
|
|
|
|
+ if (!(duration > 0)) return null
|
|
|
|
|
+ return Math.round(duration * 2) / 2
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function sumTripLegDays(legs: Array<{ duration?: unknown }>): number {
|
|
|
|
|
+ let sum = 0
|
|
|
|
|
+ for (const leg of legs || []) {
|
|
|
|
|
+ const n = Number(leg?.duration)
|
|
|
|
|
+ if (Number.isFinite(n) && n >= 0) sum += n
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.round(sum * 2) / 2
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isHalfDayMultiple(n: unknown): boolean {
|
|
|
|
|
+ const v = Number(n)
|
|
|
|
|
+ if (!Number.isFinite(v) || v < 0) return false
|
|
|
|
|
+ return Math.abs(v * 2 - Math.round(v * 2)) < 1e-9
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function emptyTripLeg(): Record<string, unknown> {
|
|
|
|
|
+ return {
|
|
|
|
|
+ transport: '',
|
|
|
|
|
+ tripType: '',
|
|
|
|
|
+ fromCity: '',
|
|
|
|
|
+ toCity: '',
|
|
|
|
|
+ startDate: '',
|
|
|
|
|
+ startHalf: '上午',
|
|
|
|
|
+ endDate: '',
|
|
|
|
|
+ endHalf: '下午',
|
|
|
|
|
+ duration: null,
|
|
|
|
|
+ durationManual: false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run — expect PASS**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd web-admin && pnpm exec tsx --test src/utils/oa-trip-days.test.ts
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add web-admin/src/utils/oa-trip-days.ts web-admin/src/utils/oa-trip-days.test.ts
|
|
|
|
|
+git commit -m "feat(oa): add trip kit half-day duration helpers"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: Backend algorithm + apply/validate (TDD)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `backend/app/services/oa_trip_days.py`
|
|
|
|
|
+- Create: `backend/tests/test_oa_trip_days.py`
|
|
|
|
|
+- Modify: `backend/app/services/oa_publish.py`
|
|
|
|
|
+- Modify: `backend/app/services/oa_engine.py`(在 leave apply 之后插入 trip apply)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write failing pytest**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# backend/tests/test_oa_trip_days.py
|
|
|
|
|
+from app.services.oa_trip_days import calc_trip_leg_days, sum_trip_leg_days, apply_trip_kit_days, validate_trip_kit_form_values
|
|
|
|
|
+
|
|
|
|
|
+def test_half_day_samples():
|
|
|
|
|
+ assert calc_trip_leg_days("2026-08-01", "上午", "2026-08-01", "上午") == 0.5
|
|
|
|
|
+ assert calc_trip_leg_days("2026-08-01", "上午", "2026-08-01", "下午") == 1.0
|
|
|
|
|
+ assert calc_trip_leg_days("2026-08-01", "下午", "2026-08-01", "上午") is None
|
|
|
|
|
+ assert calc_trip_leg_days("2026-08-01", "下午", "2026-08-02", "上午") == 1.0
|
|
|
|
|
+
|
|
|
|
|
+def test_sum():
|
|
|
|
|
+ assert sum_trip_leg_days([{"duration": 0.5}, {"duration": 2}]) == 2.5
|
|
|
|
|
+
|
|
|
|
|
+def test_apply_overwrites_when_not_manual():
|
|
|
|
|
+ schema = [{
|
|
|
|
|
+ "type": "tripKit",
|
|
|
|
|
+ "props": {"tripListField": "L1", "tripDaysField": "D1"},
|
|
|
|
|
+ "children": [
|
|
|
|
|
+ {"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
|
|
|
|
|
+ {"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
|
|
|
|
|
+ ],
|
|
|
|
|
+ }]
|
|
|
|
|
+ data = {
|
|
|
|
|
+ "L1": [{
|
|
|
|
|
+ "transport": "飞机", "tripType": "单程",
|
|
|
|
|
+ "fromCity": "合肥", "toCity": "上海",
|
|
|
|
|
+ "startDate": "2026-08-01", "startHalf": "上午",
|
|
|
|
|
+ "endDate": "2026-08-01", "endHalf": "下午",
|
|
|
|
|
+ "duration": 9, "durationManual": False,
|
|
|
|
|
+ }],
|
|
|
|
|
+ "D1": 9,
|
|
|
|
|
+ }
|
|
|
|
|
+ out = apply_trip_kit_days(schema, data)
|
|
|
|
|
+ assert out["L1"][0]["duration"] == 1.0
|
|
|
|
|
+ assert out["D1"] == 1.0
|
|
|
|
|
+
|
|
|
|
|
+def test_respect_manual_flags():
|
|
|
|
|
+ schema = [{
|
|
|
|
|
+ "type": "tripKit",
|
|
|
|
|
+ "props": {"tripListField": "L1", "tripDaysField": "D1"},
|
|
|
|
|
+ "children": [
|
|
|
|
|
+ {"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
|
|
|
|
|
+ {"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
|
|
|
|
|
+ ],
|
|
|
|
|
+ }]
|
|
|
|
|
+ data = {
|
|
|
|
|
+ "L1": [{
|
|
|
|
|
+ "startDate": "2026-08-01", "startHalf": "上午",
|
|
|
|
|
+ "endDate": "2026-08-01", "endHalf": "下午",
|
|
|
|
|
+ "duration": 3, "durationManual": True,
|
|
|
|
|
+ "transport": "火车", "tripType": "往返",
|
|
|
|
|
+ "fromCity": "a", "toCity": "b",
|
|
|
|
|
+ }],
|
|
|
|
|
+ "D1": 7,
|
|
|
|
|
+ "__manual_D1": True,
|
|
|
|
|
+ }
|
|
|
|
|
+ out = apply_trip_kit_days(schema, data)
|
|
|
|
|
+ assert out["L1"][0]["duration"] == 3
|
|
|
|
|
+ assert out["D1"] == 7
|
|
|
|
|
+
|
|
|
|
|
+def test_validate_end_before_start():
|
|
|
|
|
+ schema = [{
|
|
|
|
|
+ "type": "tripKit",
|
|
|
|
|
+ "props": {"tripListField": "L1", "tripDaysField": "D1"},
|
|
|
|
|
+ "children": [
|
|
|
|
|
+ {"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
|
|
|
|
|
+ {"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
|
|
|
|
|
+ ],
|
|
|
|
|
+ }]
|
|
|
|
|
+ data = {
|
|
|
|
|
+ "L1": [{
|
|
|
|
|
+ "transport": "飞机", "tripType": "单程",
|
|
|
|
|
+ "fromCity": "a", "toCity": "b",
|
|
|
|
|
+ "startDate": "2026-08-01", "startHalf": "下午",
|
|
|
|
|
+ "endDate": "2026-08-01", "endHalf": "上午",
|
|
|
|
|
+ "duration": 0.5, "durationManual": True,
|
|
|
|
|
+ }],
|
|
|
|
|
+ "D1": 0.5,
|
|
|
|
|
+ }
|
|
|
|
|
+ errs = validate_trip_kit_form_values(schema, data)
|
|
|
|
|
+ assert errs
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run — expect FAIL**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd backend && PYTHONPATH=. python -m pytest tests/test_oa_trip_days.py -v
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Implement `oa_trip_days.py`**
|
|
|
|
|
+
|
|
|
|
|
+要点(与前端公式一致):
|
|
|
|
|
+
|
|
|
|
|
+- `calc_trip_leg_days` / `sum_trip_leg_days` / `is_half_day_multiple`
|
|
|
|
|
+- `_walk_trip_kits(schema)`:找 `type == tripKit`,解析 `tripListField` / `tripDaysField`(props 优先,否则 `_fc_kit_field`)
|
|
|
|
|
+- `apply_trip_kit_days(schema, data)`:
|
|
|
|
|
+ - 对每行:若非 `durationManual`,用算法覆盖 `duration`;算不出则保持原值
|
|
|
|
|
+ - 若 `data.get("__manual_" + daysField)` 非真,则 `data[daysField] = sum(leg durations)`
|
|
|
|
|
+- `validate_trip_kit_form_values`:
|
|
|
|
|
+ - 列表为 list 且 `len >= 1`
|
|
|
|
|
+ - 每行必填:transport/tripType/fromCity/toCity/startDate/startHalf/endDate/endHalf
|
|
|
|
|
+ - `calc_trip_leg_days(...)` 必须非 null(即结束晚于开始)
|
|
|
|
|
+ - duration / days:`>= 0` 且半日倍数
|
|
|
|
|
+
|
|
|
|
|
+手改约定:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+MANUAL_PREFIX = "__manual_" # 出差天数
|
|
|
|
|
+# 行程行:leg["durationManual"] is True
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Wire publish + engine**
|
|
|
|
|
+
|
|
|
|
|
+`oa_publish.py`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# FILLABLE_TYPES 增加
|
|
|
|
|
+"tripItinerary",
|
|
|
|
|
+
|
|
|
|
|
+# LAYOUT_TYPES 增加
|
|
|
|
|
+"tripKit",
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`oa_engine.py`(紧接 leave 校验之后):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from app.services.oa_trip_days import apply_trip_kit_days, validate_trip_kit_form_values
|
|
|
|
|
+
|
|
|
|
|
+data = apply_trip_kit_days(schema, data)
|
|
|
|
|
+trip_errs = validate_trip_kit_form_values(schema, data)
|
|
|
|
|
+if trip_errs:
|
|
|
|
|
+ raise OaEngineError(trip_errs[0])
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: pytest PASS + commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd backend && PYTHONPATH=. python -m pytest tests/test_oa_trip_days.py -v
|
|
|
|
|
+git add backend/app/services/oa_trip_days.py backend/tests/test_oa_trip_days.py \
|
|
|
|
|
+ backend/app/services/oa_publish.py backend/app/services/oa_engine.py
|
|
|
|
|
+git commit -m "feat(oa): add trip kit day calc and submit validation"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: `tripItinerary` FormCreate control
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `web-admin/src/components/oa-approval/OaTripItinerary.vue`
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-trip-itinerary-rule.ts`
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-form-create-trip-itinerary.ts`
|
|
|
|
|
+- Modify: `web-admin/src/main.ts`(先注册 itinerary,再注册 kit)
|
|
|
|
|
+- Modify: `web-admin/src/oa-fc-host/main.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: DragRule(可不进组件菜单,仅登记 type;`menu` 可省略或 `menu: 'aide'` 不展示——推荐不 `addDragRule` 到可见菜单,仅 `component` 注册;套件 children 引用 type)**
|
|
|
|
|
+
|
|
|
|
|
+`oa-form-create-trip-itinerary.ts`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import FcDesigner from '@form-create/designer'
|
|
|
|
|
+import OaTripItinerary from '@/components/oa-approval/OaTripItinerary.vue'
|
|
|
|
|
+
|
|
|
|
|
+const FLAG = '__oaTripItineraryConfigured'
|
|
|
|
|
+
|
|
|
|
|
+export function setupOaTripItinerary(formCreate?: { component?: (name: string, c: unknown) => void }) {
|
|
|
|
|
+ if ((FcDesigner as any)[FLAG]) return
|
|
|
|
|
+ FcDesigner.component('tripItinerary', OaTripItinerary)
|
|
|
|
|
+ if (typeof formCreate?.component === 'function') {
|
|
|
|
|
+ formCreate.component('tripItinerary', OaTripItinerary)
|
|
|
|
|
+ }
|
|
|
|
|
+ ;(FcDesigner as any)[FLAG] = true
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Implement `OaTripItinerary.vue`**
|
|
|
|
|
+
|
|
|
|
|
+行为:
|
|
|
|
|
+
|
|
|
|
|
+- `modelValue: TripLeg[]`;空或非数组时初始化为 `[emptyTripLeg()]`
|
|
|
|
|
+- 每条卡片字段:交通 select、单程往返 select、出发/目的 input、开始 date+half、结束 date+half、时长 inputNumber(step 0.5)
|
|
|
|
|
+- options 来自 `props.transportOptions` / `props.tripTypeOptions`(缺省用设计默认四项/两项)
|
|
|
|
|
+- 上下午固定:`上午`/`下午`
|
|
|
|
|
+- 未 `durationManual` 时,watch 起止变化 → `calcTripLegDays` 写回 `duration`
|
|
|
|
|
+- 用户改时长 → `durationManual = true`;旁提供文字按钮「恢复自动计算」清标记并重算
|
|
|
|
|
+- 「新增行程」push `emptyTripLeg()`;「删除」在 `length > 1` 时 splice
|
|
|
|
|
+- 设计器预览:同样可渲染(FormCreate 设计器内编辑 value 可不持久,有 slot/attrs 即可)
|
|
|
|
|
+
|
|
|
|
|
+组件骨架:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="oa-trip-itinerary">
|
|
|
|
|
+ <div v-for="(leg, idx) in inner" :key="idx" class="oa-trip-leg">
|
|
|
|
|
+ <div class="oa-trip-leg__head">
|
|
|
|
|
+ <span>行程 {{ idx + 1 }}</span>
|
|
|
|
|
+ <el-button link type="danger" :disabled="inner.length <= 1" @click="remove(idx)">删除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- el-select / el-input / el-date-picker / el-input-number 绑定 leg 字段 -->
|
|
|
|
|
+ <!-- 时长旁:恢复自动计算 -->
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-button @click="add">新增行程</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+样式:Fresh Mint;卡片圆角与间距对齐现有 OA 表单(参考 `oa-fc-runtime.scss` 间距,避免另起色板)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Register in both mains**
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { setupOaTripItinerary } from '@/utils/oa-form-create-trip-itinerary'
|
|
|
|
|
+setupOaTripItinerary(FcDesigner.formCreate)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add web-admin/src/components/oa-approval/OaTripItinerary.vue \
|
|
|
|
|
+ web-admin/src/utils/oa-form-create-trip-itinerary.ts \
|
|
|
|
|
+ web-admin/src/utils/oa-trip-itinerary-rule.ts \
|
|
|
|
|
+ web-admin/src/main.ts web-admin/src/oa-fc-host/main.ts
|
|
|
|
|
+git commit -m "feat(oa): add tripItinerary FormCreate control"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(若无需单独 rule 文件,可省略 `oa-trip-itinerary-rule.ts`,本 Task 以 component 注册为准。)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: `tripKit` container + designer registration
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `web-admin/src/components/oa-approval/OaTripKit.vue`
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-trip-kit-rule.ts`
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-form-create-trip-kit.ts`
|
|
|
|
|
+- Create: `web-admin/src/utils/oa-trip-kit-runtime.ts`
|
|
|
|
|
+- Modify: `web-admin/src/main.ts` / `oa-fc-host/main.ts`
|
|
|
|
|
+- Modify: `web-admin/src/oa-fc-host/App.vue`(refresh 排除名加 `tripKit`)
|
|
|
|
|
+- Modify: `web-admin/src/utils/oa-approval-print.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: `OaTripKit.vue`** — 对齐 `OaOvertimeKit.vue`(仅 slot 容器)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: `oa-trip-kit-rule.ts`**
|
|
|
|
|
+
|
|
|
|
|
+默认 options 常量:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export const DEFAULT_TRANSPORT_OPTIONS = [
|
|
|
|
|
+ { label: '飞机', value: '飞机' },
|
|
|
|
|
+ { label: '火车', value: '火车' },
|
|
|
|
|
+ { label: '汽车', value: '汽车' },
|
|
|
|
|
+ { label: '其他', value: '其他' }
|
|
|
|
|
+]
|
|
|
|
|
+export const DEFAULT_TRIP_TYPE_OPTIONS = [
|
|
|
|
|
+ { label: '单程', value: '单程' },
|
|
|
|
|
+ { label: '往返', value: '往返' }
|
|
|
|
|
+]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`buildTripKitChildren()` 生成 field ids,children 顺序:
|
|
|
|
|
+
|
|
|
|
|
+1. textarea 事由(`type: 'input', props: { type: 'textarea', rows: 3, _fc_kit_field: 'tripReason' }`;若项目用 `textarea` type 则改用现网等价)
|
|
|
|
|
+2. `tripItinerary` 行程(props 带 transport/tripType options + `_fc_kit_field: 'tripList'`)
|
|
|
|
|
+3. `inputNumber` 出差天数(step 0.5, precision 1, `_fc_kit_field: 'tripDays'`)
|
|
|
|
|
+4. textarea 备注
|
|
|
|
|
+5. `person` multiple 同行人
|
|
|
|
|
+6. `input` 外部人员
|
|
|
|
|
+
|
|
|
|
|
+容器 props 挂 `tripReasonField` 等六个 id。
|
|
|
|
|
+
|
|
|
|
|
+DragRule:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export const tripKitDragRule = {
|
|
|
|
|
+ menu: 'kit' as const,
|
|
|
|
|
+ icon: 'icon-date',
|
|
|
|
|
+ label: '出差',
|
|
|
|
|
+ name: 'tripKit',
|
|
|
|
|
+ input: false,
|
|
|
|
|
+ drag: true,
|
|
|
|
|
+ dragBtn: true,
|
|
|
|
|
+ mask: false,
|
|
|
|
|
+ inside: true,
|
|
|
|
|
+ rule() { /* return type tripKit + props + children */ },
|
|
|
|
|
+ props() { return [] }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `setupOaTripKit`** — 同 overtime:`addMenu('kit')` + component + `addDragRule`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `oa-trip-kit-runtime.ts`**
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export type TripKitRef = {
|
|
|
|
|
+ listField: string
|
|
|
|
|
+ daysField: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listTripKits(schema: unknown): TripKitRef[]
|
|
|
|
|
+export function applyTripKitDaysToFormData(
|
|
|
|
|
+ schema: unknown,
|
|
|
|
|
+ formData: Record<string, unknown>
|
|
|
|
|
+): { data: Record<string, unknown>; changed: boolean }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+逻辑:对每个 kit,读 `listField` 数组;对非 manual 行重算 duration;若无 `__manual_${daysField}` 则写 sum。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: App.vue** — 在 kit refresh 过滤条件中加入 `name !== 'tripKit'`(与 leave/overtime/resignation 并列)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Print** — `LAYOUT_UNWRAP`(或现有 kit 列表)加入 `'tripKit'`;行程数组打印时格式化为多行文本(交通/城市/日期时段/时长)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git commit -m "feat(oa): register tripKit in FormCreate designer"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: Start page runtime linkage
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `web-admin/src/views/oa-approval/start/index.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Import** `listTripKits` / `applyTripKitDaysToFormData`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Watch formData**(可与 leave 并列独立 syncing 标志)
|
|
|
|
|
+
|
|
|
|
|
+伪逻辑:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+let tripKitSyncing = false
|
|
|
|
|
+let tripKitPrev: Record<string, unknown> = {}
|
|
|
|
|
+
|
|
|
|
|
+function syncTripKitDurations() {
|
|
|
|
|
+ if (tripKitSyncing) return
|
|
|
|
|
+ const schema = /* current form rules */
|
|
|
|
|
+ const curr = { ...formData }
|
|
|
|
|
+ for (const kit of listTripKits(schema)) {
|
|
|
|
|
+ const prevDays = tripKitPrev[kit.daysField]
|
|
|
|
|
+ const daysChanged =
|
|
|
|
|
+ prevDays !== undefined && prevDays !== curr[kit.daysField]
|
|
|
|
|
+ if (daysChanged) {
|
|
|
|
|
+ curr['__manual_' + kit.daysField] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ // 行程行内 durationManual 由 OaTripItinerary 维护
|
|
|
|
|
+ }
|
|
|
|
|
+ const { data, changed } = applyTripKitDaysToFormData(schema, curr)
|
|
|
|
|
+ tripKitPrev = { ...data }
|
|
|
|
|
+ if (!changed) return
|
|
|
|
|
+ tripKitSyncing = true
|
|
|
|
|
+ Object.assign(formData, data)
|
|
|
|
|
+ nextTick(() => { tripKitSyncing = false })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 form-create `onChange` / 现有 leave sync 钩子旁调用 `syncTripKitDurations`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 手工冒烟**
|
|
|
|
|
+
|
|
|
|
|
+1. 设计器拖「出差」→ 发布
|
|
|
|
|
+2. 发起:填事由;改行程起止看时长与出差天数
|
|
|
|
|
+3. 手改天数后改行程天数不变;清 `__manual_` 或提供恢复后汇总恢复
|
|
|
|
|
+4. 同日下午→上午提交应被后端拒绝
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git commit -m "feat(oa): wire trip kit duration sync on approval start"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 6: Spec status + graphify
|
|
|
|
|
+
|
|
|
|
|
+- [ ] 将设计文档状态改为 **已实现**
|
|
|
|
|
+- [ ] `./scripts/update-graph.sh frontend` 与 `backend`(或依赖 commit hook)
|
|
|
|
|
+- [ ] Commit docs
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git commit -m "docs(oa): mark trip kit design as implemented"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Spec coverage
|
|
|
|
|
+
|
|
|
|
|
+| Spec 项 | Task |
|
|
|
|
|
+|---------|------|
|
|
|
|
|
+| tripKit 容器 + 三部分字段 | T4 |
|
|
|
|
|
+| tripItinerary 多行程增删 ≥1 | T3 |
|
|
|
|
|
+| 钉钉半日制 0.5 步进 | T1/T2 |
|
|
|
|
|
+| 出差天数 Σ + 手改断开 | T1/T2/T5 |
|
|
|
|
|
+| 交通工具含火车 | T4 defaults |
|
|
|
|
|
+| 不回写业务表 | (无 sync 服务,默认满足) |
|
|
|
|
|
+| 单行程 end>start 校验 | T2 validate |
|
|
|
|
|
+| 不校验行程重叠 | (无代码,满足) |
|
|
|
|
|
+| 发布白名单 | T2 |
|
|
|
|
|
+| 打印 unwrap | T4 |
|
|
|
|
|
+| 条件分支仅顶层 | T4(行程数组不注册条件) |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Placeholder / consistency self-check
|
|
|
|
|
+
|
|
|
|
|
+- 手改:行 `durationManual` + 天数 `__manual_<field>` — 全任务统一。
|
|
|
|
|
+- Type 名:`tripKit` / `tripItinerary` — 与 publish / print / mains 一致。
|
|
|
|
|
+- 算法:`endPoint > startPoint` — 与 spec §4/§6 一致。
|
|
|
|
|
+- 无 TBD/「类似 Task N」未展开项。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Execution handoff
|
|
|
|
|
+
|
|
|
|
|
+Plan complete and saved to `docs/superpowers/plans/2026-07-30-oa-trip-kit.md`.
|
|
|
|
|
+
|
|
|
|
|
+**Two execution options:**
|
|
|
|
|
+
|
|
|
|
|
+1. **Subagent-Driven(推荐)** — 每 Task 派生子代理,Task 间复审
|
|
|
|
|
+2. **Inline Execution** — 本会话按 executing-plans 连续执行并设检查点
|
|
|
|
|
+
|
|
|
|
|
+Which approach?
|