docs: add implementation plan for quote/contract alias and brand manage dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-07 12:40:37 +08:00
1 parent 22014ba3fa
commit 1e74057003
1 file changed
+477
@@ -0,0 +1,477 @@
# 报价/合同明细别名与品牌就地维护 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:** 在报价单与合同产品明细的别名/品牌列旁增加维护入口,弹窗内 CRUD 写回产品主数据并立即刷新同行下拉。
**Architecture:** 新建公共 `ProductAliasBrandDialog`(仅维护 aliases 或 brands);在 `crm-product-brand.ts` 增加把产品详情同步到同 `productId` 明细行的纯函数;`QuoteFormPanel` 与 `ContractFormDialog` 接入列上 icon + `saved` 刷新。无后端变更。
**Tech Stack:** Vue 3 + Element Plus + TypeScript;现有 `@/api/crm/product`(`fetchProductDetail` / `fetchUpdateProduct`)。
**Spec:** `docs/superpowers/specs/2026-08-07-quote-contract-alias-brand-manage-design.md`
---
## File map
| 文件 | 职责 |
|------|------|
| Create: `web-admin/src/components/business/product-alias-brand-dialog/index.vue` | 别名或品牌轻量维护弹窗 |
| Modify: `web-admin/src/utils/crm-product-brand.ts` | `applyManagedProductMetaToLines`:saved 后刷新同 productId 行 |
| Modify: `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue` | 列 UI + 打开弹窗 + 刷新 |
| Modify: `web-admin/src/views/crm/contract/components/ContractFormDialog.vue` | 同上 |
---
### Task 1: 明细行刷新纯函数
**Files:**
- Modify: `web-admin/src/utils/crm-product-brand.ts`
- [ ] **Step 1: 扩展 `LineAliasBrand` 并新增 `applyManagedProductMetaToLines`**
在文件末尾(`prepareLineAliasAndBrandForSubmit` 附近)将 `LineAliasBrand` 增加可选 `productId`,并导出:
```ts
/** 主数据别名/品牌维护成功后:同步同 productId 各行的 options,并校正已选值 */
export function applyManagedProductMetaToLines(
lines: Array<LineAliasBrand & { productId?: string | number }>,
productId: string,
product: {
productName?: string
brands?: Array<{ id?: string; brandName?: string; status?: string }>
aliases?: Array<{ aliasName?: string }>
}
) {
const pid = String(productId || '')
if (!pid) return
const meta = initLineBrandMetaFromProduct(product)
for (const line of lines || []) {
if (String(line.productId || '') !== pid) continue
line.standardProductName = meta.standardProductName
line.aliasOptions = meta.aliasOptions
const alias = String(line.aliasName || '').trim()
if (alias && !meta.aliasOptions.includes(alias)) {
line.aliasName = undefined
}
line.brandOptions = meta.brandOptions
if (meta.brandOptions.length) {
if (line.brandMode === 'required' && line.brandId) {
const still = meta.brandOptions.some((o) => o.id === String(line.brandId))
if (still) {
applyBrandSelectChange(line, line.brandId, meta.brandOptions)
} else {
line.brandMode = 'any'
line.brandId = undefined
line.brandName = undefined
}
} else {
line.brandMode = 'any'
line.brandId = undefined
line.brandName = undefined
}
} else {
line.brandMode = undefined
line.brandId = undefined
line.brandName = undefined
}
}
}
```
若 `LineAliasBrand` 当前未 export,保持内部 type,函数参数用内联类型或 export 该 type 均可;关键是函数必须 `export`。
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/utils/crm-product-brand.ts
git commit -m "$(cat <<'EOF'
feat(crm): add helper to refresh quote/contract lines after product alias/brand manage.
EOF
)"
```
---
### Task 2: ProductAliasBrandDialog 组件
**Files:**
- Create: `web-admin/src/components/business/product-alias-brand-dialog/index.vue`
- [ ] **Step 1: 创建完整组件**
按下列结构实现(样式可从 `product-form-dialog/index.vue` 的 `.sub-card-*` 拷贝到本组件 scoped):
```vue
<template>
<el-dialog
v-model="visible"
class="dialog-form-layout"
:title="dialogTitle"
width="600px"
append-to-body
align-center
destroy-on-close
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
@open="handleOpen"
@closed="handleClosed"
>
<div v-loading="dialogLoading" class="manage-body">
<!-- mode === 'alias' -->
<template v-if="mode === 'alias'">
<div class="sub-card-header">
<span class="sub-card-title">别名</span>
<el-button type="primary" size="small" @click="handleAddAlias">
<el-icon><Plus /></el-icon>新增别名
</el-button>
</div>
<div v-if="aliases.length" class="sub-card-grid">
<div
v-for="(row, index) in aliases"
:key="`alias-${row.id || index}`"
class="sub-card sub-card--alias"
>
<el-input v-model="row.aliasName" placeholder="别名" class="sub-card-name" />
<el-button link type="danger" class="sub-card-remove" @click="aliases.splice(index, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div v-else class="sub-card-empty">暂无别名,点击上方新增</div>
</template>
<!-- mode === 'brand':名称 + status(crm_product_status) + 删除;顶栏新增品牌 -->
<template v-else>
<!-- 同产品表单品牌 sub-card 结构 -->
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</template>
```
Script 要点:
```ts
defineOptions({ name: 'ProductAliasBrandDialog' })
const props = defineProps<{
modelValue: boolean
productId: string
mode: 'alias' | 'brand'
productName?: string
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
saved: [product: Record<string, any>]
}>()
// visible computed ↔ modelValue
// dialogTitle: `维护别名 - ${name}` / `维护品牌 - ${name}`
// name 优先 props.productName,否则打开后详情里的 productName
async function handleOpen() {
// reset lists
// fetchProductDetail({ id: props.productId })
// 填充 aliases / brands(保留 id;brand status 默认 on)
// 失败:ElMessage.error + visible=false
}
function validateLists(): string | null {
if (props.mode === 'alias') {
const names = aliases.value.map((a) => a.aliasName.trim()).filter(Boolean)
if (names.length !== aliases.value.filter((a) => a.aliasName.trim()).length) {
// 允许空行?规格:名称必填 — 过滤空行后提交,或拦截空名称行
}
// 推荐:提交前过滤空名称;若过滤后仍有重复 → return '别名不能重复'
const trimmed = aliases.value.map((a) => a.aliasName.trim()).filter(Boolean)
if (new Set(trimmed).size !== trimmed.length) return '别名不能重复'
return null
}
// brand:过滤空名称;重复校验;每条 status 默认 on
return null
}
async function submitForm() {
const err = validateLists()
if (err) {
ElMessage.warning(err)
return
}
submitLoading.value = true
try {
const payload: Record<string, any> = { id: String(props.productId) }
if (props.mode === 'alias') {
payload.aliases = aliases.value
.filter((a) => a.aliasName?.trim())
.map((a, index) => ({
...(a.id ? { id: a.id } : {}),
aliasName: a.aliasName.trim(),
orderNum: a.orderNum ?? index + 1
}))
} else {
payload.brands = brands.value
.filter((b) => b.brandName?.trim())
.map((b, index) => ({
...(b.id ? { id: b.id } : {}),
brandName: b.brandName.trim(),
status: b.status || 'on',
orderNum: b.orderNum ?? index + 1
}))
}
await fetchUpdateProduct(payload)
// 再拉一次 detail,保证 saved payload 含最新 brands/aliases/id
const res = await fetchProductDetail({ id: String(props.productId) })
const product = (res as any)?.data || res || {}
ElMessage.success('保存成功')
visible.value = false
emit('saved', product)
} catch (e: any) {
// 保留弹窗;若 http 层未统一 toast,则 ElMessage.error(e?.message || '保存失败')
} finally {
submitLoading.value = false
}
}
```
遮罩:`modal-class="order-module-dialog-mask"`;全局已处理无 blur 则不必再写 `backdrop-filter`。
品牌模板完整字段与 `product-form-dialog` 一致(`el-select` + `dictStore.getOptions('crm_product_status')`)。
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/components/business/product-alias-brand-dialog/index.vue
git commit -m "$(cat <<'EOF'
feat(crm): add product alias/brand manage dialog for inline master-data edits.
EOF
)"
```
---
### Task 3: 接入 QuoteFormPanel
**Files:**
- Modify: `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue`
- [ ] **Step 1: 模板 — 别名/品牌列改为「控件 + icon」**
将别名列改为(品牌列同理,`mode`/`openManage` 参数换成 brand):
```vue
<el-table-column label="别名" min-width="140">
<template #default="scope">
<div class="line-meta-cell">
<el-select
v-if="(scope.row.aliasOptions || []).length"
v-model="scope.row.aliasName"
placeholder="不选则用产品名称"
clearable
filterable
size="small"
class="line-meta-cell__control"
>
<el-option
v-for="name in scope.row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="scope.row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'alias')"
>
<el-icon><Setting /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
```
品牌列:保留现有 `el-select` + `@change="handleBrandIdChange"`,外包同一 `line-meta-cell`,icon 调 `openAliasBrandManage(scope.row, 'brand')`。
- [ ] **Step 2: 挂载弹窗 + script**
在模板合适位置(产品选择器旁)增加:
```vue
<ProductAliasBrandDialog
v-model="aliasBrandManageVisible"
:product-id="aliasBrandManageProductId"
:product-name="aliasBrandManageProductName"
:mode="aliasBrandManageMode"
@saved="handleAliasBrandManaged"
/>
```
Script:
```ts
import { Setting } from '@element-plus/icons-vue' // 并入现有 icons 导入
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import { applyManagedProductMetaToLines } from '@/utils/crm-product-brand' // 并入现有 import
const aliasBrandManageVisible = ref(false)
const aliasBrandManageProductId = ref('')
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
function openAliasBrandManage(row: QuoteProductForm, mode: 'alias' | 'brand') {
if (!row.productId) return
aliasBrandManageProductId.value = String(row.productId)
aliasBrandManageProductName.value = String(
row.standardProductName || row.productName || ''
)
aliasBrandManageMode.value = mode
aliasBrandManageVisible.value = true
}
function handleAliasBrandManaged(product: Record<string, any>) {
applyManagedProductMetaToLines(
form.value.products || [],
aliasBrandManageProductId.value,
product
)
}
```
Scoped 样式:
```scss
.line-meta-cell {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.line-meta-cell__control {
flex: 1;
min-width: 0;
}
.line-meta-cell__empty {
flex: 1;
min-width: 0;
}
.line-meta-cell__btn {
flex-shrink: 0;
padding: 4px;
}
```
- [ ] **Step 3: 手动冒烟(报价)**
在 `/#/crm/quote` 打开新增/编辑:
1. 选无别名产品 → 别名列 `—` + 齿轮 → 新增别名保存 → 下拉出现并可选用
2. 品牌同理
3. 取消弹窗后主数据不变
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/crm/quote/components/QuoteFormPanel.vue
git commit -m "$(cat <<'EOF'
feat(crm): wire alias/brand manage dialog into quote product lines.
EOF
)"
```
---
### Task 4: 接入 ContractFormDialog
**Files:**
- Modify: `web-admin/src/views/crm/contract/components/ContractFormDialog.vue`
- [ ] **Step 1: 与 Task 3 相同的列布局、弹窗挂载、`openAliasBrandManage` / `handleAliasBrandManaged`**
区别仅类型为合同产品行(`ExtendedContractProductForm` 或现有行类型),刷新目标为 `form.value.products`。
导入路径、组件名、样式类名与报价保持一致,避免两套 class。
- [ ] **Step 2: 手动冒烟(合同)**
在 `/#/crm/contract` 新增/编辑合同明细:重复报价侧验收;另测「同一产品两行时维护一次两边 options 同步」。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/views/crm/contract/components/ContractFormDialog.vue
git commit -m "$(cat <<'EOF'
feat(crm): wire alias/brand manage dialog into contract product lines.
EOF
)"
```
---
### Task 5: 验收收尾与图谱
- [ ] **Step 1: 按 spec §7 全量手工验收**
| # | 检查项 |
|---|--------|
| 1 | 无数据:`—` + icon → 新增 → 下拉可用 |
| 2 | 有数据:icon 增删改 → options 立即更新 |
| 3 | 删掉当前已选别名/品牌并保存 → 清空或回退不限品牌 |
| 4 | 同 productId 多行 options 同步 |
| 5 | 品牌删除受库存/计划约束 → 弹窗保留 + 错误提示 |
| 6 | 报价与合同行为一致 |
| 7 | 取消/关闭不落库 |
- [ ] **Step 2: 更新前端图谱**
```bash
./scripts/update-graph.sh frontend
```
- [ ] **Step 3: 将设计文档状态改为已实现(可选同 commit)**
修改 `docs/superpowers/specs/2026-08-07-quote-contract-alias-brand-manage-design.md` 顶部:`状态:待实现` → `状态:已实现`。
```bash
git add docs/superpowers/specs/2026-08-07-quote-contract-alias-brand-manage-design.md web-admin/graphify-out
git commit -m "$(cat <<'EOF'
docs: mark quote/contract alias-brand manage design implemented and refresh graph.
EOF
)"
```
---
## Spec 覆盖自检
| Spec 需求 | Task |
|-----------|------|
| 公共弹窗写回主数据、即时保存 | Task 2 |
| 别名/品牌列 icon(有无 options 都显示) | Task 3、4 |
| 仅传 aliases 或 brands | Task 2 `submitForm` |
| 同 productId 刷新 + 校正已选 | Task 1 + 3/4 |
| 不自动选中新建项 | Task 1(只更新 options) |
| 报价 + 合同 | Task 3、4 |
| 不做查看态 / contract/form.vue / 新接口 | 未列入任务 |
| 品牌删除约束错误保留弹窗 | Task 2 catch |
| 验收 | Task 5 |