docs: add mp-webview CRM quote implementation plan.

Break the quote manage/approve H5 migration into TDD-friendly tasks aligned with the product webview pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-15 15:43:16 +08:00
1 parent 88cb1a9f62
commit 121e431f68
1 file changed
+942
@@ -0,0 +1,942 @@
# 小程序 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/quote*`,提供管理闭环(列表/详情/增改删/单条提交审批/复制新增/作废/版本历史只读)与审批闭环(待审/已审列表 + 单条通过驳回),不含分享、转合同、富文本/PDF、导出与批量。
**Architecture:** 复用运输计划 / CRM 产品 WebView 模板(ticket → exchange → `MpWebviewShell`)。新建移动端报价页,样式对齐 `/#/m/pl/plan*` 与 `/#/m/crm/product*`(`mp-webview.scss` 三墙 Token)。业务 API 全部复用 `@/api/crm/quote`。桌面 `QuoteFormDialog`/`QuoteFormPanel` 增加 `variant="page"`;列表抽共享组件 + `mode`。菜单 SQL 幂等写入 `MINI_CRM_QUOTE*`,按 Web `crm:quote:list` / `crm:quote:approve` 角色分别补绑。
**Tech Stack:** Vue 3 · Vue Router hash · Element Plus · Pinia · uni-app web-view · MySQL `sys_menu`/`sys_role_menu` · Node test(`pnpm exec tsx --test`)
**Spec:** `docs/superpowers/specs/2026-10-15-mp-webview-crm-quote-design.md`
---
## 文件结构
| 路径 | 职责 |
|------|------|
| `web-admin/src/utils/mp-crm-quote-card.ts` | 列表 Tab/查询参数、卡片字段、金额格式(可单测) |
| `web-admin/src/utils/mp-crm-quote-card.test.ts` | 纯函数单测 |
| `web-admin/src/utils/mp-webview.ts` | 增加 `MP_CRM_QUOTE_LIST_PATH`、`MP_CRM_QUOTE_APPROVAL_PATH` |
| `web-admin/src/views/m/crm/quote/MpQuoteList.vue` | 共享列表(mode=manage\|approve) |
| `web-admin/src/views/m/crm/quote/index.vue` | 管理列表壳 |
| `web-admin/src/views/m/crm/quote/approval.vue` | 审批列表壳 |
| `web-admin/src/views/m/crm/quote/detail.vue` | 详情 + 管理/审批底栏 |
| `web-admin/src/views/m/crm/quote/form.vue` | 路由壳,挂载 `QuoteFormDialog variant="page"` |
| `web-admin/src/views/m/crm/quote/versions.vue` | 版本历史只读 |
| `web-admin/src/views/crm/quote/components/QuoteFormDialog.vue` | 增加 `variant: 'dialog' \| 'page'` |
| `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue` | page 模式单列布局(若 dialog 透传不够再改) |
| `web-admin/src/router/routes/staticRoutes.ts` | 注册 `/m/crm/quote*`(catch-all 前) |
| `miniprogram/config/workbench-menus.js` | 两项 enabled + code + perms |
| `miniprogram/config/api.js` | `WORKBENCH_ROUTE_MAP` 两项 |
| `docs/sql/migrations/2026-10-15-mini-crm-quote-webview-menus.sql` | 菜单 + 角色补绑 |
| `docs/web-admin-mp-webview-modules.md` | 补充报价对照行 |
**明确不改:** `mp-webview.vue` 鉴权协议、后端报价业务 API 语义、桌面 `/#/crm/quote*` 默认行为(仅表单组件增加可选 `variant`,默认仍为 `dialog`)。
**查询语义(重要,与桌面审批 Tab 不同):**
| mode | view | 请求要点 |
|------|------|----------|
| manage | `all` | `excludeStatus=invalid`;不传 status / pending / approved |
| manage | `submitted` | `status=submitted`;**不**传 `pendingApprovalUserId` |
| manage | `accepted` | `status=accepted`;**不**传 `approvedByUserId` |
| approve | `submitted` | `status=submitted` + `pendingApprovalUserId=当前用户` |
| approve | `accepted` | `approvedByUserId=当前用户`(与桌面审批「已审批」一致) |
分页字段用 `pageNum` / `pageSize`(对齐 `QuoteQuery`,不是产品的 `page`)。
---
### Task 1: 列表卡片纯函数 + 单测
**Files:**
- Create: `web-admin/src/utils/mp-crm-quote-card.ts`
- Create: `web-admin/src/utils/mp-crm-quote-card.test.ts`
- [ ] **Step 1: 写失败单测**
```ts
// web-admin/src/utils/mp-crm-quote-card.test.ts
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
MP_QUOTE_MANAGE_TABS,
MP_QUOTE_APPROVE_TABS,
buildQuoteListQuery,
buildQuoteCard,
formatQuoteMoney,
quoteTypeLabel,
quoteTypeTagType
} from './mp-crm-quote-card'
describe('tabs', () => {
it('manage has all/submitted/accepted; approve has submitted/accepted', () => {
assert.deepEqual(
MP_QUOTE_MANAGE_TABS.map((x) => x.value),
['all', 'submitted', 'accepted']
)
assert.deepEqual(
MP_QUOTE_APPROVE_TABS.map((x) => x.value),
['submitted', 'accepted']
)
})
})
describe('buildQuoteListQuery', () => {
it('manage all excludes invalid and trims keyword', () => {
assert.deepEqual(
buildQuoteListQuery({
mode: 'manage',
view: 'all',
pageNum: 1,
pageSize: 20,
keyword: ' 酸 ',
currentUserId: 'u1'
}),
{
pageNum: 1,
pageSize: 20,
keyword: '酸',
excludeStatus: 'invalid'
}
)
})
it('manage submitted sets status only', () => {
const q = buildQuoteListQuery({
mode: 'manage',
view: 'submitted',
pageNum: 2,
pageSize: 10,
keyword: '',
currentUserId: 'u1'
})
assert.equal(q.status, 'submitted')
assert.equal(q.excludeStatus, undefined)
assert.equal('pendingApprovalUserId' in q, false)
assert.equal('keyword' in q, false)
})
it('manage accepted sets status accepted', () => {
const q = buildQuoteListQuery({
mode: 'manage',
view: 'accepted',
pageNum: 1,
pageSize: 20,
keyword: '',
currentUserId: 'u1'
})
assert.equal(q.status, 'accepted')
assert.equal('approvedByUserId' in q, false)
})
it('approve submitted uses pendingApprovalUserId', () => {
const q = buildQuoteListQuery({
mode: 'approve',
view: 'submitted',
pageNum: 1,
pageSize: 20,
keyword: '',
currentUserId: 'u9'
})
assert.equal(q.status, 'submitted')
assert.equal(q.pendingApprovalUserId, 'u9')
})
it('approve accepted uses approvedByUserId', () => {
const q = buildQuoteListQuery({
mode: 'approve',
view: 'accepted',
pageNum: 1,
pageSize: 20,
keyword: 'x',
currentUserId: 'u9'
})
assert.equal(q.approvedByUserId, 'u9')
assert.equal(q.keyword, 'x')
assert.equal('status' in q && q.status !== undefined, false)
})
})
describe('buildQuoteCard', () => {
it('maps fields and money', () => {
const card = buildQuoteCard({
id: 'q1',
quoteNo: 'BJ001',
quoteTitle: '硫酸报价',
customerName: '甲公司',
quoteType: 'sales',
finalAmount: 1234.5,
status: 'draft',
quoteDate: '2026-10-01'
})
assert.equal(card.id, 'q1')
assert.equal(card.quoteNo, 'BJ001')
assert.equal(card.quoteTypeLabel, '销售')
assert.equal(card.quoteTypeTagType, 'success')
assert.equal(card.amountText, '¥1,234.50')
assert.equal(card.status, 'draft')
})
})
describe('quoteType helpers', () => {
it('maps purchase/sales', () => {
assert.equal(quoteTypeLabel('purchase'), '采购')
assert.equal(quoteTypeTagType('purchase'), 'warning')
assert.equal(quoteTypeLabel('sales'), '销售')
assert.equal(formatQuoteMoney(1000), '¥1,000.00')
assert.equal(formatQuoteMoney(undefined), '—')
})
})
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd web-admin && pnpm exec tsx --test src/utils/mp-crm-quote-card.test.ts
```
Expected: 无法解析 `./mp-crm-quote-card` 或 FAIL
- [ ] **Step 3: 实现纯函数**
```ts
// web-admin/src/utils/mp-crm-quote-card.ts
export type MpQuoteMode = 'manage' | 'approve'
export type MpQuoteView = 'all' | 'submitted' | 'accepted'
export const MP_QUOTE_MANAGE_TABS: Array<{ value: MpQuoteView; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'submitted', label: '待审批' },
{ value: 'accepted', label: '已审批' }
]
export const MP_QUOTE_APPROVE_TABS: Array<{ value: Exclude<MpQuoteView, 'all'>; label: string }> = [
{ value: 'submitted', label: '待审批' },
{ value: 'accepted', label: '已审批' }
]
export function quoteTypeLabel(type?: string): string {
if ((type || '').trim() === 'purchase') return '采购'
return '销售'
}
export function quoteTypeTagType(type?: string): 'success' | 'warning' {
if ((type || '').trim() === 'purchase') return 'warning'
return 'success'
}
export function formatQuoteMoney(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 buildQuoteListQuery(input: {
mode: MpQuoteMode
view: MpQuoteView
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 === 'submitted') {
params.status = 'submitted'
} else {
params.status = 'accepted'
}
return params
}
// approve
if (input.view === 'submitted') {
params.status = 'submitted'
params.pendingApprovalUserId = input.currentUserId
} else {
params.approvedByUserId = input.currentUserId
}
return params
}
export function buildQuoteCard(row: {
id: string | number
quoteNo?: string
quoteTitle?: string
customerName?: string
quoteType?: string
finalAmount?: number
totalAmount?: number
status?: string
quoteDate?: string
}) {
const amount =
row.finalAmount !== undefined && row.finalAmount !== null
? row.finalAmount
: row.totalAmount
return {
id: String(row.id),
quoteNo: row.quoteNo || '—',
quoteTitle: row.quoteTitle || '—',
customerName: row.customerName || '—',
quoteType: row.quoteType || 'sales',
quoteTypeLabel: quoteTypeLabel(row.quoteType),
quoteTypeTagType: quoteTypeTagType(row.quoteType),
amountText: formatQuoteMoney(amount),
status: row.status || '',
quoteDate: row.quoteDate || '—'
}
}
```
状态 Tag 文案/类型在页面用 `dictStore.getLabel/getTagType('crm_quote_status', status)`(与桌面一致);纯函数只保留 `status` 原值。
- [ ] **Step 4: 跑测确认通过**
```bash
cd web-admin && pnpm exec tsx --test src/utils/mp-crm-quote-card.test.ts
```
Expected: 全部 pass
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/utils/mp-crm-quote-card.ts web-admin/src/utils/mp-crm-quote-card.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp CRM quote card query helpers.
EOF
)"
```
---
### Task 2: 菜单 SQL(报价两叶子 + 角色补绑)
**Files:**
- Create: `docs/sql/migrations/2026-10-15-mini-crm-quote-webview-menus.sql`
- [ ] **Step 1: 写入完整幂等 SQL**
```sql
-- 小程序:CRM管理 → 报价单管理 / 报价单审批(WebView)
-- list 随 CRM3000000000000000000000000010 (crm:quote:list)
-- approve 随 CRM3000000000000000000000000016 (crm:quote: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_QUOTE', '报价单管理', 'MINI_CRM_ROOT', 'C', 'mp-webview', NULL,
'mini:crm:quote:list',
1, '1', '1', 'shanxia2.0', 2, '小程序 WebView CRM 报价单管理',
'system', '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_CRM_QUOTE' 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_QUOTE_APPROVAL', '报价单审批', 'MINI_CRM_ROOT', 'C', 'mp-webview', NULL,
'mini:crm:quote:approve',
1, '1', '1', 'shanxia2.0', 3, '小程序 WebView CRM 报价单审批',
'system', '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_CRM_QUOTE_APPROVAL' AND `del_flag` = 0
);
-- 有 Web 报价单管理权 → 补绑根 + CRM目录 + 报价单管理
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-quote-webview|v1')),
r.id,
m.menu_id,
IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'),
0,
'小程序CRM报价单管理:随 Web crm:quote: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 = 'CRM3000000000000000000000000010'
AND web.del_flag = 0
JOIN (
SELECT '1' AS menu_id UNION ALL
SELECT 'MINI_CRM_ROOT' UNION ALL
SELECT 'MINI_CRM_QUOTE'
) 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
);
-- 有 Web 报价单审批权 → 补绑根 + CRM目录 + 报价单审批
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-quote-approval-webview|v1')),
r.id,
m.menu_id,
IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'),
0,
'小程序CRM报价单审批:随 Web crm:quote: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 = 'CRM3000000000000000000000000016'
AND web.del_flag = 0
JOIN (
SELECT '1' AS menu_id UNION ALL
SELECT 'MINI_CRM_ROOT' UNION ALL
SELECT 'MINI_CRM_QUOTE_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 只交 SQL;测试库实施时执行)**
```bash
git add docs/sql/migrations/2026-10-15-mini-crm-quote-webview-menus.sql
git commit -m "$(cat <<'EOF'
docs(sql): add miniprogram CRM quote 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`
- [ ] **Step 1: 增加路径常量**
在 `MP_CRM_PRODUCT_LIST_PATH` 旁增加:
```ts
export const MP_CRM_QUOTE_LIST_PATH = '/m/crm/quote'
export const MP_CRM_QUOTE_APPROVAL_PATH = '/m/crm/quote/approval'
```
- [ ] **Step 2: 在 catch-all 之前注册路由(顺序固定)**
插在 `/m/crm/product/:id` 与 `/:pathMatch(.*)*` 之间:
```ts
{
path: '/m/crm/quote',
name: 'MpCrmQuoteList',
component: () => import('@views/m/crm/quote/index.vue'),
meta: { title: '报价单管理', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/quote/approval',
name: 'MpCrmQuoteApproval',
component: () => import('@views/m/crm/quote/approval.vue'),
meta: { title: '报价单审批', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/quote/new',
name: 'MpCrmQuoteNew',
component: () => import('@views/m/crm/quote/form.vue'),
meta: { title: '新增报价单', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/quote/:id/edit',
name: 'MpCrmQuoteEdit',
component: () => import('@views/m/crm/quote/form.vue'),
meta: { title: '编辑报价单', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/quote/:id/versions',
name: 'MpCrmQuoteVersions',
component: () => import('@views/m/crm/quote/versions.vue'),
meta: { title: '版本历史', isHideTab: true, isMpWebview: true }
},
{
path: '/m/crm/quote/:id',
name: 'MpCrmQuoteDetail',
component: () => import('@views/m/crm/quote/detail.vue'),
meta: { title: '报价单详情', isHideTab: true, isMpWebview: true }
},
```
本步可先建空壳 vue(仅 `MpWebviewShell` + 标题),后续 Task 填满;**不得**把路由放在 catch-all 之后。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/utils/mp-webview.ts web-admin/src/router/routes/staticRoutes.ts \
web-admin/src/views/m/crm/quote/index.vue \
web-admin/src/views/m/crm/quote/approval.vue \
web-admin/src/views/m/crm/quote/form.vue \
web-admin/src/views/m/crm/quote/detail.vue \
web-admin/src/views/m/crm/quote/versions.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): register /m/crm/quote static routes.
EOF
)"
```
---
### Task 4: 共享列表 + 管理/审批入口页
**Files:**
- Create: `web-admin/src/views/m/crm/quote/MpQuoteList.vue`
- Modify: `web-admin/src/views/m/crm/quote/index.vue`
- Modify: `web-admin/src/views/m/crm/quote/approval.vue`
对照:`web-admin/src/views/m/crm/product/index.vue`(Header / filter / card / speed-dial / `goToMiniWorkbench`)。
- [ ] **Step 1: 实现 `MpQuoteList.vue`**
Props:`mode: 'manage' | 'approve'`。
关键行为:
1. `MpWebviewShell @ready` 后再拉列表
2. 当前用户 id:与桌面 `QuoteListPage` 的 `resolveCurrentUserId` 同源(查 user store;实现时打开该文件复制同一取值,勿猜字段名)
3. `listQuote(buildQuoteListQuery({ mode, view, pageNum, pageSize, keyword, currentUserId }))`
4. 解析 `res.list` / `res.rows` + `total`(与桌面 `getList` 一致)
5. Tab:manage 用 `MP_QUOTE_MANAGE_TABS`(默认 `all`);approve 用 `MP_QUOTE_APPROVE_TABS`(默认 `submitted`);UI 可用 `mp-wv-filter-bar` 或横向 pill,样式对齐产品筛选
6. 搜索 placeholder:`报价主题 / 客户 / 单号`;enter / 清除后 reload
7. 卡片字段:`buildQuoteCard` + 状态 `el-tag`(`dictStore`)
8. 点击详情:`router.push({ path: \`/m/crm/quote/${id}\`, query: { from: mode } })`
9. manage 才有 Speed Dial「新增报价单」→ `/m/crm/quote/new`;approve **无** Speed Dial
10. 返回:`goToMiniWorkbench()`(与产品列表相同)
11. `import '@/styles/mp-webview.scss'`
12. **不要**出现:多选、导出、facet、统计条、日期快捷、批量、分享、生成报价单
卡片模板要点:
```vue
<article
v-for="card in cards"
:key="card.id"
class="mp-wv-card"
@click="goDetail(card.id)"
>
<div class="mp-wv-card__title">{{ card.quoteTitle }}</div>
<div class="mp-wv-card__meta">{{ card.quoteNo }} · {{ card.customerName }}</div>
<div class="mp-wv-card__meta">{{ card.amountText }} · {{ card.quoteDate }}</div>
<div class="mp-wv-card__tags">
<el-tag :type="card.quoteTypeTagType" size="small">{{ card.quoteTypeLabel }}</el-tag>
<el-tag :type="statusTagType(card.status)" size="small">{{ statusLabel(card.status) }}</el-tag>
</div>
</article>
```
分页:与产品页一致「加载更多」(`pageNum++`)。
403 → `forbidden` 显示「无权限」。
- [ ] **Step 2: 薄壳页**
`index.vue`:
```vue
<template>
<MpQuoteList mode="manage" />
</template>
<script setup lang="ts">
import MpQuoteList from './MpQuoteList.vue'
</script>
```
`approval.vue`:`mode="approve"`。
- [ ] **Step 3: 本地验证**
```bash
cd web-admin && pnpm dev
```
打开 `/#/m/crm/quote`、`/#/m/crm/quote/approval`(桌面登录或 mp 调试),确认 Tab/搜索/卡片。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/m/crm/quote/MpQuoteList.vue \
web-admin/src/views/m/crm/quote/index.vue \
web-admin/src/views/m/crm/quote/approval.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp webview CRM quote list pages.
EOF
)"
```
---
### Task 5: 详情页(管理动作 + 审批动作)
**Files:**
- Create/Modify: `web-admin/src/views/m/crm/quote/detail.vue`
对照:`web-admin/src/views/m/crm/product/detail.vue`;字段与动作对齐 `QuoteDetailDialog.vue` + `crm/quote/detail.vue`(删除用 `canDeleteByCreator`)。
- [ ] **Step 1: 实现详情加载与分区展示**
1. `getQuote(id)`;必要时 `listQuoteProduct` / 详情已带 `productList`;`listApproval(id)` 展示审批流
2. 分区:基础信息、金额/条款、产品明细(卡片或简易表)、审批流、备注附件(有则展示)
3. `from`:`route.query.from === 'approve' ? 'approve' : 'manage'`
4. 返回列表:`from===approve` → `MP_CRM_QUOTE_APPROVAL_PATH`,否则 `MP_CRM_QUOTE_LIST_PATH`
5. 加载 skeleton 用运输计划/产品同款 class
- [ ] **Step 2: 管理底栏(`from!==approve`)**
按 spec §5.4 + `quoteStatus.ts`:
| 条件 | 按钮 |
|------|------|
| `status==='draft'` | 提交审批(主色)、编辑、删除(`canDeleteByCreator`)、作废、复制新增、版本历史 |
| `isQuoteEditableStatus` 且非 draft | 编辑(先 `getQuoteReeditConfirmMessage` 确认)、作废、复制新增、版本历史 |
| `status==='invalid'` | 复制新增、版本历史 |
| 始终禁止 | 生成报价单、分享、转合同、通过/驳回 |
动作:
- 编辑 → `/m/crm/quote/:id/edit`
- 复制新增 → `/m/crm/quote/new?copyFrom=:id`
- 版本历史 → `/m/crm/quote/:id/versions`
- 删除 → 确认后 `delQuote([id])`,成功回管理列表
- 作废 → 输入原因后 `invalidQuote(id, reason)`,成功刷新
- 提交审批 → 打开 Bottom Sheet(见 Step 3)
- [ ] **Step 3: 提交审批 Sheet(单条)**
1. 打开时 `getQuoteApproverSetting()` 预填 `approverIds`(与桌面 submit 对话框同源字段)
2. 审批人多选(有序:用 `el-select multiple` 或与桌面 `ApproverSelector` 一致的数据源——打开 `QuoteListPage` / `QuoteFormPanel` 看审批人列表从哪来,复用同一 API)
3. 备注可选
4. 确认 → `submitQuote({ ids: [id], approverIds, remark })`(以 `SubmitApprovalForm` 实际字段为准,打开 `types.ts` 核对,勿猜)
5. 成功关闭 Sheet 并刷新详情
- [ ] **Step 4: 审批底栏(`from===approve`)**
1. 从 `listApproval` 判定当前用户是否有 `pending` 节点;有则显示通过/驳回
2. 通过/驳回:结果 + 意见(驳回必填);调用 `approveQuote`(字段对齐桌面 `ApprovalForm`)
3. **不出现**编辑/提交/删除/作废/复制
- [ ] **Step 5: 手工验证**
草稿提交 → 审批页可见 → 通过/驳回;管理页无审批按钮。
- [ ] **Step 6: Commit**
```bash
git add web-admin/src/views/m/crm/quote/detail.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp webview CRM quote detail actions.
EOF
)"
```
---
### Task 6: QuoteFormDialog `variant="page"` + form 路由壳
**Files:**
- Modify: `web-admin/src/views/crm/quote/components/QuoteFormDialog.vue`
- Modify: `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue`(page 布局)
- Create/Modify: `web-admin/src/views/m/crm/quote/form.vue`
对照:`ProductFormDialog` 的 `variant` / `isPageVariant`;`views/m/crm/product/form.vue`。
- [ ] **Step 1: QuoteFormDialog 增加 variant(默认 dialog)**
```ts
const props = withDefaults(
defineProps<{
modelValue?: boolean
variant?: 'dialog' | 'page'
/** page 模式:add | edit | copy */
pageMode?: 'add' | 'edit' | 'copy'
quoteId?: string | number | null
copyFromId?: string | number | null
}>(),
{ variant: 'dialog', modelValue: false, pageMode: 'add' }
)
const isPageVariant = computed(() => props.variant === 'page')
```
行为:
- `dialog`:保持现有 `open` / `openEdit` / `openCopy` + `el-dialog`,桌面调用处不传 `variant`
- `page`:不渲染 `el-dialog`;根为 `div.mp-quote-form-page`;内嵌 `QuoteFormPanel`;`onMounted`/`watch` 按 `pageMode` 调 `initNew` / `initEdit` / `initCopy`
- page 底栏:取消(Close)/ 保存草稿(DocumentCopy)/ 提交审批(CircleCheck)——与桌面 footer 三按钮一致,调用 panel 同名方法;或若 panel 自带 footer(`hideFooter=false`)则 page 模式 `hideFooter=false` 并加 `mp-plan-form-footer` 样式覆盖
- 成功 `@success`、关闭 `@close`;page 模式 `modelValue` 可恒 `true`
- [ ] **Step 2: QuoteFormPanel page 视觉**
当 `variant==='page'`(新增 prop,默认 `dialog`):
- 主表栅格改为单列(覆盖多列 grid)
- 明细行改为移动端卡片编辑(禁止宽桌面表原样横向溢出;可横向滚动作为底线,优先卡片)
- 使用三墙相关间距;**不要**改 dialog 默认布局
若改动面过大:允许 page 模式先「单列 + 明细横向滚动」,但字段集合必须与桌面一致(全字段)。
- [ ] **Step 3: form.vue 壳**
```vue
<template>
<MpWebviewShell>
<header class="mp-wv-header">
<div class="mp-wv-header__row">
<button class="mp-wv-header__back" type="button" @click="goBack">←</button>
<h1 class="mp-wv-header__title">{{ pageTitle }}</h1>
</div>
</header>
<QuoteFormDialog
variant="page"
:model-value="true"
:page-mode="pageMode"
:quote-id="quoteId"
:copy-from-id="copyFromId"
@success="onSuccess"
@close="goBack"
/>
</MpWebviewShell>
</template>
```
- `MpCrmQuoteNew` + `copyFrom` query → `pageMode='copy'`,`copyFromId`
- `MpCrmQuoteNew` 无 copy → `pageMode='add'`
- `MpCrmQuoteEdit` → `pageMode='edit'`,`quoteId=route.params.id`
- 成功:优先进详情(若接口返回 id);否则回 `MP_CRM_QUOTE_LIST_PATH`
- `import '@/styles/mp-webview.scss'`
- [ ] **Step 4: 验证全字段保存(含明细行)与复制新增**
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/views/crm/quote/components/QuoteFormDialog.vue \
web-admin/src/views/crm/quote/components/QuoteFormPanel.vue \
web-admin/src/views/m/crm/quote/form.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add page variant for quote form in mp webview.
EOF
)"
```
---
### Task 7: 版本历史只读页
**Files:**
- Create/Modify: `web-admin/src/views/m/crm/quote/versions.vue`
对照:`VersionHistoryDialog.vue` 列表字段;**只做只读**(不做对比/恢复/复制为新版本/下载 PDF)。
- [ ] **Step 1: 实现**
1. 先 `getQuote(id)` 取 `quoteNo`,再 `queryVersions(quoteNo)`
2. 时间线或卡片:版本号、状态 Tag、金额、日期、是否当前版本
3. 「查看」→ `router.push({ path: \`/m/crm/quote/${version.id}\`, query: { from: 'manage' } })`(只读进详情即可)
4. 返回:`router.push(\`/m/crm/quote/${route.params.id}\`)` 或 `router.back()`
5. **不出现**下拉:对比 / 设为当前 / 复制为新版本 / 下载 PDF
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/views/m/crm/quote/versions.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add mp webview CRM quote version history page.
EOF
)"
```
---
### Task 8: 小程序工作台入口
**Files:**
- Modify: `miniprogram/config/workbench-menus.js`
- Modify: `miniprogram/config/api.js`
- [ ] **Step 1: CRM menus 增加两项**
```js
{
key: 'crm',
title: 'CRM 管理',
desc: '产品 · 报价',
iconText: 'CRM',
visible: true,
menus: [
{
name: '产品管理',
enabled: true,
code: 'crm-product-webview',
icon: '📦',
perms: 'mini:crm:product:list'
},
{
name: '报价单管理',
enabled: true,
code: 'crm-quote-webview',
icon: '📝',
perms: 'mini:crm:quote:list'
},
{
name: '报价单审批',
enabled: true,
code: 'crm-quote-approval-webview',
icon: '✅',
perms: 'mini:crm:quote:approve'
}
]
}
```
- [ ] **Step 2: WORKBENCH_ROUTE_MAP**
```js
'crm-product-webview':
PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/product'),
'crm-quote-webview':
PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/quote'),
'crm-quote-approval-webview':
PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/crm/quote/approval')
```
禁止新建第二个 web-view 页。
- [ ] **Step 3: Commit**
```bash
git add miniprogram/config/workbench-menus.js miniprogram/config/api.js
git commit -m "$(cat <<'EOF'
feat(miniprogram): enable CRM quote webview workbench entries.
EOF
)"
```
---
### Task 9: 接入文档 + 回归清单
**Files:**
- Modify: `docs/web-admin-mp-webview-modules.md`
- [ ] **Step 1: 在「CRM 产品对照」后增加报价对照**
```markdown
## CRM 报价对照
- 管理列表 `/m/crm/quote`,审批列表 `/m/crm/quote/approval`,详情 `/:id`,表单 `/new` `/:id/edit`,版本 `/:id/versions`
- 工作台 code `crm-quote-webview` / `crm-quote-approval-webview`,perms `mini:crm:quote:list` / `mini:crm:quote:approve`
- 业务 API:现有报价 list/detail/CRUD/submit/invalid/versions/approval;审批人设置仅 get 预填
- 不做:分享、转合同、生成报价单/PDF、导出、批量提交/审批、审批人设置菜单
```
并将文首「已落地」句补上报价。
- [ ] **Step 2: 回归(对照 spec §10)**
在测试库执行 Task 2 SQL 后:
1. 有 `crm:quote:list` 的角色:工作台见「报价单管理」;有 `crm:quote:approve` 见「报价单审批」
2. 管理列表 Tab/搜索与桌面同账号数据合理一致;审批列表待审默认
3. 新增草稿 → 详情提交审批(选手+备注)→ 审批入口单条通过/驳回
4. 管理页无审批按钮;审批页无编辑/作废/提交
5. 复制新增、作废、版本历史只读;再编辑确认文案
6. 全字段表单(含明细)保存回显
7. 401 回小程序;移动端无分享/转合同/PDF/导出/批量
8. 桌面 `/#/crm/quote`、`/#/crm/quote/approval` 仍为 dialog 形态正常
- [ ] **Step 3: Commit**
```bash
git add docs/web-admin-mp-webview-modules.md
git commit -m "$(cat <<'EOF'
docs: document CRM quote as mp webview module.
EOF
)"
```
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| 嵌套 H5 / 复用壳 | 3–8(不改 ticket 协议) |
| 管理闭环 B(含复制/作废/版本) | 4, 5, 6, 7 |
| 双入口 + 单条审批 | 4, 5 |
| 不做分享/转合同/PDF/导出/批量/审批人设置 | 4–7 禁止;Task 9 回归 |
| 全字段表单 + page variant | 6 |
| 提交时选手 + 预填默认人 | 5 Step 3 |
| Tab + keyword | 1, 4 |
| 三墙样式 | 4–7 `mp-webview.scss` |
| 菜单 + 角色补绑 | 2 |
| 工作台 + route map | 8 |
| 双权限 | 2 + 8 + 现有 API |
| 接入文档 | 9 |
**占位符扫描:** 无 TBD;审批人列表数据源、`SubmitApprovalForm` 字段要求实现时打开现有桌面代码核对(已写明路径)。
---
## 执行说明
实施前建议在隔离 worktree(`using-git-worktrees`)。每 Task 完成后跑对应单测/手工点,再 commit。后端无新接口;本地需能访问现有 CRM 报价 API。测试库执行菜单 SQL。