diff --git a/docs/superpowers/plans/2026-08-07-quote-contract-alias-brand-manage.md b/docs/superpowers/plans/2026-08-07-quote-contract-alias-brand-manage.md new file mode 100644 index 000000000..bd274a744 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-quote-contract-alias-brand-manage.md @@ -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, + 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 + +``` + +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] +}>() + +// 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 = { 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-select` + `@change="handleBrandIdChange"`,外包同一 `line-meta-cell`,icon 调 `openAliasBrandManage(scope.row, 'brand')`。 + +- [ ] **Step 2: 挂载弹窗 + script** + +在模板合适位置(产品选择器旁)增加: + +```vue + +``` + +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) { + 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 |