20 KiB
小程序 WebView · CRM 合同 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: 在小程序工作台开放「CRM管理 → 合同管理 / 合同审批」,通过既有 mp-webview 壳打开 web-admin /#/m/crm/contract*,提供管理闭环(列表/详情/增改删/提交审批/复制/作废·恢复/上传扫描件)与审批闭环(待审/已审 + 单条与批量通过驳回),不含调价函、打印/PDF、导出、执行、续签。
Architecture: 对齐报价 WebView(ticket → exchange → MpWebviewShell)。新建 views/m/crm/contract/*,样式 mp-webview.scss。业务 API 复用 @/api/crm/contract。桌面合同表单增加 variant="page"。列表支持多选批量。菜单 SQL 幂等写入 MINI_CRM_CONTRACT*,按 Web crm:contract:list / crm:contract:approve 分别补绑。
Tech Stack: Vue 3 · Vue Router hash · Element Plus · Pinia · uni-app web-view · MySQL · Node test(pnpm exec tsx --test)
Spec: docs/superpowers/specs/2026-10-15-mp-webview-crm-contract-design.md
文件结构
| 路径 | 职责 |
|---|---|
web-admin/src/utils/mp-crm-contract-card.ts |
Tab/查询/卡片纯函数 |
web-admin/src/utils/mp-crm-contract-card.test.ts |
单测 |
web-admin/src/utils/mp-webview.ts |
MP_CRM_CONTRACT_LIST_PATH、MP_CRM_CONTRACT_APPROVAL_PATH |
web-admin/src/views/m/crm/contract/MpContractList.vue |
共享列表(含多选批量) |
web-admin/src/views/m/crm/contract/index.vue |
管理壳 |
web-admin/src/views/m/crm/contract/approval.vue |
审批壳 |
web-admin/src/views/m/crm/contract/detail.vue |
详情 + 动作 |
web-admin/src/views/m/crm/contract/form.vue |
表单路由壳 |
web-admin/src/views/crm/contract/components/ContractFormDialog.vue |
variant=page |
web-admin/src/router/routes/staticRoutes.ts |
/m/crm/contract* |
miniprogram/config/workbench-menus.js |
两项入口 |
miniprogram/config/api.js |
WORKBENCH_ROUTE_MAP |
docs/sql/migrations/2026-10-15-mini-crm-contract-webview-menus.sql |
菜单 + 补绑 |
docs/web-admin-mp-webview-modules.md |
合同对照行 |
明确不改: mp-webview 鉴权协议、后端合同业务语义、桌面合同默认 dialog 行为、调价函。
查询语义:
| mode | view | 请求 |
|---|---|---|
| manage | all |
excludeStatus=invalid |
| manage | approving |
status=approving |
| manage | approved |
status=approved |
| approve | submitted |
pendingApprovalUserId=currentUserId |
| approve | approved |
approvedByUserId=currentUserId |
分页:pageNum / pageSize。
Task 1: 列表卡片纯函数 + 单测
Files:
-
Create:
web-admin/src/utils/mp-crm-contract-card.ts -
Create:
web-admin/src/utils/mp-crm-contract-card.test.ts -
Step 1: 写失败单测
// web-admin/src/utils/mp-crm-contract-card.test.ts
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
MP_CONTRACT_MANAGE_TABS,
MP_CONTRACT_APPROVE_TABS,
buildContractListQuery,
buildContractCard,
formatContractMoney
} from './mp-crm-contract-card'
describe('tabs', () => {
it('manage all/approving/approved; approve submitted/approved', () => {
assert.deepEqual(
MP_CONTRACT_MANAGE_TABS.map((x) => x.value),
['all', 'approving', 'approved']
)
assert.deepEqual(
MP_CONTRACT_APPROVE_TABS.map((x) => x.value),
['submitted', 'approved']
)
})
})
describe('buildContractListQuery', () => {
it('manage all excludes invalid and trims keyword', () => {
assert.deepEqual(
buildContractListQuery({
mode: 'manage',
view: 'all',
pageNum: 1,
pageSize: 20,
keyword: ' 合同 ',
currentUserId: 'u1'
}),
{ pageNum: 1, pageSize: 20, keyword: '合同', excludeStatus: 'invalid' }
)
})
it('manage approving sets status only', () => {
const q = buildContractListQuery({
mode: 'manage',
view: 'approving',
pageNum: 1,
pageSize: 20,
keyword: '',
currentUserId: 'u1'
})
assert.equal(q.status, 'approving')
assert.equal('pendingApprovalUserId' in q, false)
})
it('manage approved sets status approved', () => {
const q = buildContractListQuery({
mode: 'manage',
view: 'approved',
pageNum: 1,
pageSize: 20,
keyword: '',
currentUserId: 'u1'
})
assert.equal(q.status, 'approved')
assert.equal('approvedByUserId' in q, false)
})
it('approve submitted uses pendingApprovalUserId', () => {
const q = buildContractListQuery({
mode: 'approve',
view: 'submitted',
pageNum: 1,
pageSize: 20,
keyword: '',
currentUserId: 'u9'
})
assert.equal(q.pendingApprovalUserId, 'u9')
})
it('approve approved uses approvedByUserId', () => {
const q = buildContractListQuery({
mode: 'approve',
view: 'approved',
pageNum: 1,
pageSize: 20,
keyword: 'x',
currentUserId: 'u9'
})
assert.equal(q.approvedByUserId, 'u9')
assert.equal(q.keyword, 'x')
})
})
describe('buildContractCard', () => {
it('maps fields', () => {
const card = buildContractCard({
id: 'c1',
contractNo: 'HT001',
contractTitle: '硫酸销售合同',
customerName: '甲公司',
amount: 1000,
freight: 50,
barrelDeposit: 20,
status: 'draft',
signDate: '2026-10-01'
})
assert.equal(card.id, 'c1')
assert.equal(card.contractNo, 'HT001')
assert.equal(card.amountText, '¥1,070.00')
assert.equal(card.status, 'draft')
})
})
describe('formatContractMoney', () => {
it('formats and handles empty', () => {
assert.equal(formatContractMoney(1000), '¥1,000.00')
assert.equal(formatContractMoney(undefined), '—')
})
})
- Step 2: 跑测确认失败
cd web-admin && pnpm exec tsx --test src/utils/mp-crm-contract-card.test.ts
Expected: FAIL / module not found
- Step 3: 实现纯函数
// web-admin/src/utils/mp-crm-contract-card.ts
export type MpContractMode = 'manage' | 'approve'
export type MpContractManageView = 'all' | 'approving' | 'approved'
export type MpContractApproveView = 'submitted' | 'approved'
export type MpContractView = MpContractManageView | MpContractApproveView
export const MP_CONTRACT_MANAGE_TABS: Array<{ value: MpContractManageView; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'approving', label: '待审批' },
{ value: 'approved', label: '已审批' }
]
export const MP_CONTRACT_APPROVE_TABS: Array<{ value: MpContractApproveView; label: string }> = [
{ value: 'submitted', label: '待审批' },
{ value: 'approved', label: '已审批' }
]
export function formatContractMoney(amount?: number | null): string {
if (amount === undefined || amount === null || Number.isNaN(Number(amount))) return '—'
return `¥${Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`
}
export function buildContractListQuery(input: {
mode: MpContractMode
view: MpContractView
pageNum: number
pageSize: number
keyword: string
currentUserId: string
}): Record<string, unknown> {
const params: Record<string, unknown> = {
pageNum: input.pageNum,
pageSize: input.pageSize
}
const kw = String(input.keyword || '').trim()
if (kw) params.keyword = kw
if (input.mode === 'manage') {
if (input.view === 'all') params.excludeStatus = 'invalid'
else if (input.view === 'approving') params.status = 'approving'
else params.status = 'approved'
return params
}
if (input.view === 'submitted') {
params.pendingApprovalUserId = input.currentUserId
} else {
params.approvedByUserId = input.currentUserId
}
return params
}
export function buildContractCard(row: {
id: string | number
contractNo?: string
contractTitle?: string
customerName?: string
amount?: number
freight?: number
barrelDeposit?: number
status?: string
signDate?: string
}) {
const total =
(Number(row.amount) || 0) + (Number(row.freight) || 0) + (Number(row.barrelDeposit) || 0)
return {
id: String(row.id),
contractNo: row.contractNo || '—',
contractTitle: row.contractTitle || '—',
customerName: row.customerName || '—',
amountText: formatContractMoney(total),
status: row.status || '',
signDate: row.signDate || '—'
}
}
状态 Tag 在页面用 dictStore crm_contract_status。
- Step 4: 跑测确认通过
cd web-admin && pnpm exec tsx --test src/utils/mp-crm-contract-card.test.ts
- Step 5: Commit
git add web-admin/src/utils/mp-crm-contract-card.ts web-admin/src/utils/mp-crm-contract-card.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp CRM contract card query helpers.
EOF
)"
Task 2: 菜单 SQL
Files:
-
Create:
docs/sql/migrations/2026-10-15-mini-crm-contract-webview-menus.sql -
Step 1: 写入幂等 SQL(结构对齐报价脚本)
-- 小程序:CRM管理 → 合同管理 / 合同审批(WebView)
-- list 随 CRM3000000000000000000000000020 (crm:contract:list)
-- approve 随 CRM3000000000000000000000000021 (crm:contract:approve)
SET NAMES utf8mb4;
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
'MINI_CRM_ROOT', 'CRM管理', '1', 'M', '/mini/crm', NULL, NULL,
1, '1', '1', 'shanxia2.0', 2, '小程序CRM管理',
'system', '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_CRM_ROOT' AND `del_flag` = 0
);
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
'MINI_CRM_CONTRACT', '合同管理', 'MINI_CRM_ROOT', 'C', 'mp-webview', NULL,
'mini:crm:contract:list',
1, '1', '1', 'shanxia2.0', 4, '小程序 WebView CRM 合同管理',
'system', '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_CRM_CONTRACT' AND `del_flag` = 0
);
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
'MINI_CRM_CONTRACT_APPROVAL', '合同审批', 'MINI_CRM_ROOT', 'C', 'mp-webview', NULL,
'mini:crm:contract:approve',
1, '1', '1', 'shanxia2.0', 5, '小程序 WebView CRM 合同审批',
'system', '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_CRM_CONTRACT_APPROVAL' AND `del_flag` = 0
);
INSERT INTO `sys_role_menu` (
`id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT
MD5(CONCAT(r.id, '|', m.menu_id, '|mini-crm-contract-webview|v1')),
r.id,
m.menu_id,
IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'),
0,
'小程序CRM合同管理:随 Web crm:contract:list 补绑',
'system', '', NOW(), NULL, NOW(), 0
FROM `sys_role` r
JOIN `sys_role_menu` web
ON web.role_id = r.id
AND web.menu_id = 'CRM3000000000000000000000000020'
AND web.del_flag = 0
JOIN (
SELECT '1' AS menu_id UNION ALL
SELECT 'MINI_CRM_ROOT' UNION ALL
SELECT 'MINI_CRM_CONTRACT'
) m
WHERE r.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_role_menu` x
WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0
);
INSERT INTO `sys_role_menu` (
`id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT
MD5(CONCAT(r.id, '|', m.menu_id, '|mini-crm-contract-approval-webview|v1')),
r.id,
m.menu_id,
IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'),
0,
'小程序CRM合同审批:随 Web crm:contract:approve 补绑',
'system', '', NOW(), NULL, NOW(), 0
FROM `sys_role` r
JOIN `sys_role_menu` web
ON web.role_id = r.id
AND web.menu_id = 'CRM3000000000000000000000000021'
AND web.del_flag = 0
JOIN (
SELECT '1' AS menu_id UNION ALL
SELECT 'MINI_CRM_ROOT' UNION ALL
SELECT 'MINI_CRM_CONTRACT_APPROVAL'
) m
WHERE r.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_role_menu` x
WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0
);
- Step 2: Commit(本 Task 不连库执行)
git add docs/sql/migrations/2026-10-15-mini-crm-contract-webview-menus.sql
git commit -m "$(cat <<'EOF'
docs(sql): add miniprogram CRM contract webview menus and role bind.
EOF
)"
Task 3: 静态路由 + 路径常量 + 空壳
Files:
-
Modify:
web-admin/src/utils/mp-webview.ts -
Modify:
web-admin/src/router/routes/staticRoutes.ts -
Create shells under
web-admin/src/views/m/crm/contract/ -
Step 1: 常量
export const MP_CRM_CONTRACT_LIST_PATH = '/m/crm/contract'
export const MP_CRM_CONTRACT_APPROVAL_PATH = '/m/crm/contract/approval'
- Step 2: 路由(catch-all 前;顺序固定)
{
path: '/m/crm/contract',
name: 'MpCrmContractList',
component: () => import('@views/m/crm/contract/index.vue'),
meta: { title: '合同管理', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/contract/approval',
name: 'MpCrmContractApproval',
component: () => import('@views/m/crm/contract/approval.vue'),
meta: { title: '合同审批', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/contract/new',
name: 'MpCrmContractNew',
component: () => import('@views/m/crm/contract/form.vue'),
meta: { title: '新增合同', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/contract/:id/edit',
name: 'MpCrmContractEdit',
component: () => import('@views/m/crm/contract/form.vue'),
meta: { title: '编辑合同', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/contract/:id',
name: 'MpCrmContractDetail',
component: () => import('@views/m/crm/contract/detail.vue'),
meta: { title: '合同详情', isHideTab: true, isMpWebview: true }
},
-
Step 3: 最小壳 vue(
MpWebviewShell+ title;同报价 Task 3) -
Step 4: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): register /m/crm/contract static routes.
EOF
)"
Task 4: 共享列表 + 多选批量
Files:
- Create:
web-admin/src/views/m/crm/contract/MpContractList.vue - Replace:
index.vue/approval.vue薄壳
对照:views/m/crm/quote/MpQuoteList.vue,并增加多选。
- Step 1: 实现
MpContractList.vue
- Prop
mode: 'manage' | 'approve' @ready后listContract(buildContractListQuery(...));解析list|rows+totalresolveCurrentUserId从MpQuoteList/ContractListPage复制- Tab + keyword + 卡片(
buildContractCard+ dict 状态 Tag) - 多选:卡片左侧 checkbox;
selectedIds;底栏「已选 N」 - manage:Speed Dial 新增;批量「提交审批」(打开 Sheet,仅草稿 id);可选批量作废(逐条
invalidContract,跳过不可作废) - approve:批量通过/驳回(Sheet;驳回意见必填);对每条解析 pending approval id(对齐桌面:
listContractApproval找当前用户 pending),调用approvePass/approveReject(打开api/crm/contract/approval.ts核对字段) - 详情:
/m/crm/contract/${id}?from=${mode} goToMiniWorkbench();mp-webview.scss;403→无权限- 禁止:导出、打印、执行、续签、facet
批量提交 Sheet:getContractApproverSetting 预填 + fetchUserOptions;batchSubmitApproval({ ids, approverIds, remark })。
- Step 2: 薄壳
<template><MpContractList mode="manage" /></template>
approval:mode="approve"。
- Step 3: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp webview CRM contract list pages.
EOF
)"
Task 5: 详情页动作
Files:
- Create/Modify:
web-admin/src/views/m/crm/contract/detail.vue
对照:views/m/crm/quote/detail.vue + 桌面 ContractDetailDialog 动作集合(排除执行/续签/打印)。
- Step 1: 加载与展示
getContract + 明细 + listContractApproval;分区展示;扫描件 URL 可预览。
- Step 2: 管理底栏
| 条件 | 按钮 |
|---|---|
| draft | 提交审批、编辑、删除(canDeleteByCreator)、作废、复制、上传扫描件 |
| 可作废状态(draft/approving/approved/executing 等,对齐桌面) | 作废、复制、上传;可编辑则编辑 |
| invalid | 作废恢复、复制 |
| 永不出现 | 执行、续签、打印、审批通过驳回 |
动作 API:submitApproval、invalidContract、restoreContract、delContract、uploadFile/uploadAttachments(对照 UploadDialog.vue)、复制 → /new?copyFrom=。
- Step 3: 审批底栏
待我审 → 通过/驳回 Sheet → approvePass/approveReject。
- Step 4: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp webview CRM contract detail actions.
EOF
)"
Task 6: 表单 variant="page"
Files:
- Modify:
web-admin/src/views/crm/contract/components/ContractFormDialog.vue(及内部 Panel 若有) - Create/Modify:
web-admin/src/views/m/crm/contract/form.vue
对照报价 QuoteFormDialog page 模式与 views/m/crm/quote/form.vue。
- Step 1: 默认
variant=dialog;page 模式无 el-dialog、单列布局、sticky 底栏(取消/保存) - Step 2: form.vue 支持 add / edit / copyFrom
- Step 3: 验证全字段保存
- Step 4: Commit
git commit -m "$(cat <<'EOF'
feat(web-admin): add page variant for contract form in mp webview.
EOF
)"
Task 7: 工作台入口
Files:
-
Modify:
miniprogram/config/workbench-menus.js -
Modify:
miniprogram/config/api.js -
Step 1: CRM menus 增加两项
{
name: '合同管理',
enabled: true,
code: 'crm-contract-webview',
icon: '📄',
perms: 'mini:crm:contract:list'
},
{
name: '合同审批',
enabled: true,
code: 'crm-contract-approval-webview',
icon: '✅',
perms: 'mini:crm:contract:approve'
}
desc 更新含「合同」。不要加调价函。
- Step 2: WORKBENCH_ROUTE_MAP
'crm-contract-webview':
PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/contract'),
'crm-contract-approval-webview':
PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/contract/approval')
- Step 3: Commit
git commit -m "$(cat <<'EOF'
feat(miniprogram): enable CRM contract webview workbench entries.
EOF
)"
Task 8: 文档 + 回归清单
Files:
-
Modify:
docs/web-admin-mp-webview-modules.md -
Step 1: 增加 CRM 合同对照
## CRM 合同对照
- 管理 `/m/crm/contract`,审批 `/m/crm/contract/approval`,详情 `/:id`,表单 `/new` `/:id/edit`
- 工作台 code `crm-contract-webview` / `crm-contract-approval-webview`
- perms `mini:crm:contract:list` / `mini:crm:contract:approve`
- 支持单条与批量提交/审批;含复制、作废/恢复、上传扫描件
- 不做:调价函、打印/PDF、导出、执行、续签
文首「已落地」补上合同。
-
Step 2: 回归(spec §10);测试库执行 Task 2 SQL
-
Step 3: Commit
git commit -m "$(cat <<'EOF'
docs: document CRM contract as mp webview module.
EOF
)"
Spec 覆盖自检
| Spec 项 | Task |
|---|---|
| 嵌套 H5 | 3–7 |
| 管理闭环 B + 批量 | 4, 5 |
| 全字段表单 | 6 |
| 不做调价函/打印/导出/执行/续签 | 4–8 禁止 |
| 菜单 + 补绑 | 2 |
| 工作台 | 7 |
| 文档 | 8 |
执行说明
当前分支 V1.5 可直接实现(与报价同模式)。每 Task 完成后 commit。后端无新接口。测试库执行菜单 SQL。