docs: add implementation plan for quote/contract alias and brand manage dialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 |
|
||||
Reference in new issue
Block a user