Files
shanxia2.0/docs/superpowers/plans/2026-08-21-dict-select-ensure.md
T
2026-08-21 16:50:47 +08:00

30 KiB
Raw Blame History

字典下拉手输即入库(DictSelect + ensure)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: 白名单字典下拉支持手输;二次确认后立刻写入 sys_dict_data;本会话新增值可在下拉旁软删;报价/合同/运输计划/客户/线索统一用 DictSelect。

Architecture: 后端通用 ensure_dict_data / ensure_remove_dict_data(白名单)+ POST /web/admin/dict-data/ensure 与 ensure-remove;前端 dictStore.ensure* + DictSelect 单一入口替换各页手写 el-select。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + Pinia;pytest

Spec: docs/superpowers/specs/2026-08-21-dict-select-ensure-design.md


文件结构

操作 文件 职责
Create backend/app/services/dict_ensure.py 白名单、ensure、ensure-remove(不 commit,由 API commit)
Create backend/tests/test_dict_ensure.py 服务层单测
Modify backend/app/api/admin.py 注册 ensure / ensure-remove 路由
Modify web-admin/src/api/admin.ts fetchEnsureDictData / fetchEnsureRemoveDictData
Create web-admin/src/constants/dict-ensure-whitelist.ts 前后端对齐的白名单常量(前端)
Modify web-admin/src/store/modules/dict.ts ensure、ensureRemove、sessionCreated、本地 upsert
Create web-admin/src/components/DictSelect/index.vue 可创建字典下拉统一组件
Modify web-admin/src/views/crm/quote/components/QuoteFormPanel.vue 单位/包装规格/送货方式 → DictSelect
Modify web-admin/src/views/crm/contract/components/ContractFormDialog.vue 同上
Modify web-admin/src/views/crm/contract/form.vue 同上
Modify web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue 同上
Modify web-admin/src/views/crm/customer/CustomerFormDialog.vue 园区 → DictSelect
Modify web-admin/src/views/crm/clue/form.vue 园区 → DictSelect

Task 1: 后端 ensure 服务 + 单测

Files:

  • Create: backend/app/services/dict_ensure.py

  • Create: backend/tests/test_dict_ensure.py

  • Step 1: 写失败单测(服务尚不存在)

# backend/tests/test_dict_ensure.py
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from app.models.models import SysDictData
from app.services.dict_ensure import (
    DICT_ENSURE_WHITELIST,
    DictEnsureError,
    ensure_dict_data,
    ensure_remove_dict_data,
)


def test_whitelist_contains_crm_types():
    assert "crm_product_gg" in DICT_ENSURE_WHITELIST
    assert "crm_shfs" in DICT_ENSURE_WHITELIST
    assert "crm_product_unit" in DICT_ENSURE_WHITELIST
    assert "crm_customer_park" in DICT_ENSURE_WHITELIST


def test_ensure_rejects_unknown_dict_type():
    db = MagicMock()
    with pytest.raises(DictEnsureError, match="不支持"):
        ensure_dict_data(
            db, tenant_no="t1", dict_type="sys_user_sex", label="男", user_id="u1"
        )


def test_ensure_rejects_empty_label():
    db = MagicMock()
    with pytest.raises(DictEnsureError, match="不能为空"):
        ensure_dict_data(
            db, tenant_no="t1", dict_type="crm_shfs", label="  ", user_id="u1"
        )


def test_ensure_reuses_existing_label():
    db = MagicMock()
    existing = SimpleNamespace(dict_label="自提", dict_value="自提")
    q = MagicMock()
    q.filter.return_value.first.return_value = existing
    db.query.return_value = q

    result = ensure_dict_data(
        db, tenant_no="t1", dict_type="crm_shfs", label="自提", user_id="u1"
    )
    assert result == {"label": "自提", "value": "自提", "created": False}
    db.add.assert_not_called()


def test_ensure_inserts_new_with_value_equals_label():
    db = MagicMock()
    q = MagicMock()
    # existing by label: None; clash by value: None
    q.filter.return_value.first.side_effect = [None, None]
    db.query.return_value = q

    result = ensure_dict_data(
        db, tenant_no="t1", dict_type="crm_product_gg", label="吨袋装", user_id="u1"
    )
    assert result == {"label": "吨袋装", "value": "吨袋装", "created": True}
    db.add.assert_called_once()
    db.flush.assert_called_once()
    db.commit.assert_not_called()
    added = db.add.call_args[0][0]
    assert isinstance(added, SysDictData)
    assert added.dict_type == "crm_product_gg"
    assert added.dict_label == "吨袋装"
    assert added.dict_value == "吨袋装"
    assert added.tenant_no == "t1"
    assert added.create_by == "u1"
  • Step 2: 跑测确认失败

Run:

cd backend && source venv/bin/activate && pytest tests/test_dict_ensure.py -v

Expected: IMPORT ERROR 或 FAIL(模块不存在)

  • Step 3: 实现 dict_ensure.py
# backend/app/services/dict_ensure.py
"""业务页字典 ensure:白名单内按 label 复用或插入 sys_dict_data。"""
from __future__ import annotations

from datetime import datetime
from typing import Dict, Optional, Set

from sqlalchemy.orm import Session

from app.core.uuid_util import generate_uuid
from app.models.models import SysDictData

DICT_ENSURE_WHITELIST: Set[str] = {
    "crm_product_gg",
    "crm_shfs",
    "crm_product_unit",
    "crm_customer_park",
}


class DictEnsureError(ValueError):
    pass


def ensure_dict_data(
    db: Session,
    *,
    tenant_no: str,
    dict_type: str,
    label: str,
    user_id: str = "",
    preferred_value: Optional[str] = None,
) -> Dict[str, object]:
    label = (label or "").strip()
    if not label:
        raise DictEnsureError("字典标签不能为空")
    if dict_type not in DICT_ENSURE_WHITELIST:
        raise DictEnsureError("不支持的字典类型")

    preferred = (preferred_value or "").strip() or label

    existing_label = (
        db.query(SysDictData)
        .filter(
            SysDictData.tenant_no == tenant_no,
            SysDictData.dict_type == dict_type,
            SysDictData.dict_label == label,
            SysDictData.del_flag == 0,
        )
        .first()
    )
    if existing_label:
        return {
            "label": existing_label.dict_label,
            "value": existing_label.dict_value,
            "created": False,
        }

    by_value = (
        db.query(SysDictData)
        .filter(
            SysDictData.tenant_no == tenant_no,
            SysDictData.dict_type == dict_type,
            SysDictData.dict_value == preferred,
            SysDictData.del_flag == 0,
        )
        .first()
    )
    if by_value:
        return {
            "label": by_value.dict_label,
            "value": by_value.dict_value,
            "created": False,
        }

    obj = SysDictData(
        id=generate_uuid(),
        dict_label=label,
        dict_value=preferred,
        dict_type=dict_type,
        tag_type="",
        tenant_no=tenant_no,
        order_num=0,
        remark=None,
        create_by=user_id or None,
        create_dept_id="",
        create_time=datetime.now(),
        update_by=user_id or None,
        update_time=datetime.now(),
        del_flag=0,
    )
    db.add(obj)
    db.flush()
    return {"label": obj.dict_label, "value": obj.dict_value, "created": True}

(本 Task 先不实现 ensure_remove_dict_data,下一步再加。)

  • Step 4: 跑测确认 ensure 相关用例通过

Run:

cd backend && source venv/bin/activate && pytest tests/test_dict_ensure.py -v -k "ensure and not remove"

Expected: PASS(若 Step 1 已写 remove 相关测例且未实现,用 -k 过滤或暂时注释 remove 测例)

  • Step 5: Commit(若用户允许提交)
git add backend/app/services/dict_ensure.py backend/tests/test_dict_ensure.py
git commit -m "$(cat <<'EOF'
feat: add whitelisted dict ensure service for business selects

EOF
)"

Task 2: 后端 ensure-remove + 单测

Files:

  • Modify: backend/app/services/dict_ensure.py

  • Modify: backend/tests/test_dict_ensure.py

  • Step 1: 追加 remove 单测

def test_ensure_remove_soft_deletes_own_row():
    db = MagicMock()
    row = SimpleNamespace(
        dict_value="吨袋装",
        create_by="u1",
        del_flag=0,
        update_by=None,
        update_time=None,
    )
    q = MagicMock()
    q.filter.return_value.first.return_value = row
    db.query.return_value = q

    ensure_remove_dict_data(
        db,
        tenant_no="t1",
        dict_type="crm_product_gg",
        value="吨袋装",
        user_id="u1",
    )
    assert row.del_flag == 1
    assert row.update_by == "u1"
    db.add.assert_called_once_with(row)
    db.flush.assert_called_once()


def test_ensure_remove_rejects_other_users_row():
    db = MagicMock()
    row = SimpleNamespace(dict_value="吨袋装", create_by="other", del_flag=0)
    q = MagicMock()
    q.filter.return_value.first.return_value = row
    db.query.return_value = q

    with pytest.raises(DictEnsureError, match="无权|不能删除|不允许"):
        ensure_remove_dict_data(
            db,
            tenant_no="t1",
            dict_type="crm_product_gg",
            value="吨袋装",
            user_id="u1",
        )


def test_ensure_remove_rejects_unknown_type():
    db = MagicMock()
    with pytest.raises(DictEnsureError, match="不支持"):
        ensure_remove_dict_data(
            db,
            tenant_no="t1",
            dict_type="sys_user_sex",
            value="1",
            user_id="u1",
        )
  • Step 2: 跑测确认失败

Run:

cd backend && source venv/bin/activate && pytest tests/test_dict_ensure.py -v -k remove

Expected: FAIL(函数未定义)

  • Step 3: 实现 ensure_remove_dict_data

在 dict_ensure.py 追加:

def ensure_remove_dict_data(
    db: Session,
    *,
    tenant_no: str,
    dict_type: str,
    value: str,
    user_id: str = "",
) -> None:
    value = (value or "").strip()
    if not value:
        raise DictEnsureError("字典值不能为空")
    if dict_type not in DICT_ENSURE_WHITELIST:
        raise DictEnsureError("不支持的字典类型")

    row = (
        db.query(SysDictData)
        .filter(
            SysDictData.tenant_no == tenant_no,
            SysDictData.dict_type == dict_type,
            SysDictData.dict_value == value,
            SysDictData.del_flag == 0,
        )
        .first()
    )
    if not row:
        raise DictEnsureError("字典数据不存在")
    if (row.create_by or "") != (user_id or ""):
        raise DictEnsureError("无权删除该字典项")

    row.del_flag = 1
    row.update_by = user_id or None
    row.update_time = datetime.now()
    db.add(row)
    db.flush()
  • Step 4: 全量跑 test_dict_ensure.py

Run:

cd backend && source venv/bin/activate && pytest tests/test_dict_ensure.py -v

Expected: 全部 PASS

  • Step 5: Commit(若允许)
git add backend/app/services/dict_ensure.py backend/tests/test_dict_ensure.py
git commit -m "$(cat <<'EOF'
feat: add dict ensure-remove with creator-only soft delete

EOF
)"

Task 3: 注册 admin API 路由

Files:

  • Modify: backend/app/api/admin.py(在现有 # 字典数据管理 段落后追加,/dict-data/all 附近)

  • Step 1: 增加 Body 与两个 endpoint

在 admin.py 顶部 import:

from app.services import dict_ensure as dict_ensure_svc

在字典数据段追加(鉴权用 require_admin_or_super_admin,与业务页登录态一致,不要用 system:dict:list):

class DictEnsureBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    dict_type: str = Field(..., alias="dictType")
    label: str
    preferred_value: Optional[str] = Field(None, alias="preferredValue")


class DictEnsureRemoveBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    dict_type: str = Field(..., alias="dictType")
    value: str


@router.post("/dict-data/ensure")
def ensure_dict_data_api(
    body: DictEnsureBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        data = dict_ensure_svc.ensure_dict_data(
            db,
            tenant_no=tenant_no,
            dict_type=body.dict_type,
            label=body.label,
            user_id=user.get("user_id") or user.get("id") or "",
            preferred_value=body.preferred_value,
        )
        db.commit()
        return success_response(data, message="已保存")
    except dict_ensure_svc.DictEnsureError as e:
        db.rollback()
        return error_response(400, str(e))


@router.post("/dict-data/ensure-remove")
def ensure_remove_dict_data_api(
    body: DictEnsureRemoveBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        dict_ensure_svc.ensure_remove_dict_data(
            db,
            tenant_no=tenant_no,
            dict_type=body.dict_type,
            value=body.value,
            user_id=user.get("user_id") or user.get("id") or "",
        )
        db.commit()
        return success_response(None, message="已删除")
    except dict_ensure_svc.DictEnsureError as e:
        db.rollback()
        return error_response(400, str(e))

注意:若 FastAPI 把 /dict-data/{data_id} 抢在 /dict-data/ensure 之前,需保证 ensure 路由声明在带 path param 的路由之前,或确认现有文件中静态路径已在动态路径之上。

  • Step 2: 导入检查

Run:

cd backend && source venv/bin/activate && python -c "from app.api.admin import router; print('ok')"

Expected: ok

  • Step 3: Commit(若允许)
git add backend/app/api/admin.py
git commit -m "$(cat <<'EOF'
feat: expose dict-data ensure and ensure-remove admin APIs

EOF
)"

Task 4: 前端 API + 白名单常量 + dictStore

Files:

  • Create: web-admin/src/constants/dict-ensure-whitelist.ts

  • Modify: web-admin/src/api/admin.ts(在 fetchAllDictData 附近)

  • Modify: web-admin/src/store/modules/dict.ts

  • Step 1: 白名单常量

// web-admin/src/constants/dict-ensure-whitelist.ts
export const DICT_ENSURE_WHITELIST = [
  'crm_product_gg',
  'crm_shfs',
  'crm_product_unit',
  'crm_customer_park'
] as const

export type DictEnsureType = (typeof DICT_ENSURE_WHITELIST)[number]

export function isDictEnsureType(dictType: string): dictType is DictEnsureType {
  return (DICT_ENSURE_WHITELIST as readonly string[]).includes(dictType)
}
  • Step 2: API 方法
// 追加到 web-admin/src/api/admin.ts
export function fetchEnsureDictData(data: {
  dictType: string
  label: string
  preferredValue?: string
}) {
  return request.post<{ label: string; value: string; created: boolean }>({
    url: '/web/admin/dict-data/ensure',
    data
  })
}

export function fetchEnsureRemoveDictData(data: { dictType: string; value: string }) {
  return request.post({
    url: '/web/admin/dict-data/ensure-remove',
    data
  })
}

(若项目 request.post 习惯用 params 而非 data,与同文件 fetchCreateDictData 保持一致。)

  • Step 3: 扩展 dictStore

将 web-admin/src/store/modules/dict.ts 改为包含:

import { defineStore } from 'pinia'
import {
  fetchAllDictData,
  fetchEnsureDictData,
  fetchEnsureRemoveDictData
} from '@/api/admin'
import { isDictEnsureType } from '@/constants/dict-ensure-whitelist'

interface DictItem {
  label: string
  value: string
  tagType: string
}

export const useDictStore = defineStore('dictStore', {
  state: () => ({
    dictMap: {} as Record<string, DictItem[]>,
    loaded: false,
    /** 本会话 ensure 新插入的 value,用于下拉显示删除按钮 */
    sessionCreated: {} as Record<string, string[]>
  }),

  getters: {
    getLabel: (state) => (dictType: string, value: string): string => {
      const items = state.dictMap[dictType]
      if (!items) return value || '—'
      const item = items.find((i) => i.value === value)
      return item?.label ?? value ?? '—'
    },
    getTagType: (state) => (dictType: string, value: string): string => {
      const items = state.dictMap[dictType]
      if (!items) return 'info'
      const item = items.find((i) => i.value === value)
      return item?.tagType || 'info'
    },
    getOptions: (state) => (dictType: string): DictItem[] => {
      return state.dictMap[dictType] || []
    },
    isSessionCreated:
      (state) =>
      (dictType: string, value: string): boolean => {
        return (state.sessionCreated[dictType] || []).includes(value)
      }
  },

  actions: {
    async loadAllDict() {
      if (this.loaded) return
      try {
        const res = await fetchAllDictData()
        this.dictMap = (res as any)?.data || res || {}
        this.loaded = true
      } catch (e) {
        console.error('加载字典数据失败', e)
      }
    },
    clearDict() {
      this.dictMap = {}
      this.loaded = false
      this.sessionCreated = {}
    },
    upsertLocal(dictType: string, item: { label: string; value: string; tagType?: string }) {
      const list = this.dictMap[dictType] ? [...this.dictMap[dictType]] : []
      const idx = list.findIndex((i) => i.value === item.value)
      const row: DictItem = {
        label: item.label,
        value: item.value,
        tagType: item.tagType || 'info'
      }
      if (idx >= 0) list[idx] = row
      else list.push(row)
      this.dictMap[dictType] = list
    },
    removeLocal(dictType: string, value: string) {
      const list = this.dictMap[dictType] || []
      this.dictMap[dictType] = list.filter((i) => i.value !== value)
      const created = this.sessionCreated[dictType] || []
      this.sessionCreated[dictType] = created.filter((v) => v !== value)
    },
    markSessionCreated(dictType: string, value: string) {
      const created = this.sessionCreated[dictType] ? [...this.sessionCreated[dictType]] : []
      if (!created.includes(value)) created.push(value)
      this.sessionCreated[dictType] = created
    },
    async ensure(dictType: string, label: string, preferredValue?: string) {
      const text = (label || '').trim()
      if (!text) throw new Error('字典标签不能为空')
      if (!isDictEnsureType(dictType)) throw new Error('不支持的字典类型')
      const res = await fetchEnsureDictData({
        dictType,
        label: text,
        preferredValue
      })
      const data = (res as any)?.data || res
      this.upsertLocal(dictType, { label: data.label, value: data.value })
      if (data.created) this.markSessionCreated(dictType, data.value)
      return data as { label: string; value: string; created: boolean }
    },
    async ensureRemove(dictType: string, value: string) {
      const v = (value || '').trim()
      if (!v) throw new Error('字典值不能为空')
      if (!isDictEnsureType(dictType)) throw new Error('不支持的字典类型')
      await fetchEnsureRemoveDictData({ dictType, value: v })
      this.removeLocal(dictType, v)
    }
  }
})
  • Step 4: Commit(若允许)
git add web-admin/src/constants/dict-ensure-whitelist.ts web-admin/src/api/admin.ts web-admin/src/store/modules/dict.ts
git commit -m "$(cat <<'EOF'
feat: add dict ensure API client and dictStore session helpers

EOF
)"

Task 5: 实现 DictSelect 组件

Files:

  • Create: web-admin/src/components/DictSelect/index.vue

  • Step 1: 创建组件

<template>
  <el-select
    :model-value="modelValue"
    :placeholder="placeholder"
    :clearable="clearable"
    :filterable="filterable"
    :allow-create="allowCreate"
    :default-first-option="allowCreate"
    :disabled="disabled || loading"
    :size="size"
    :style="style"
    @update:model-value="onSelect"
  >
    <el-option
      v-for="opt in options"
      :key="opt.value"
      :label="opt.label"
      :value="opt.value"
    >
      <div class="dict-select-option">
        <span>{{ opt.label }}</span>
        <el-button
          v-if="dictStore.isSessionCreated(dictType, opt.value)"
          link
          type="danger"
          size="small"
          class="dict-select-option__remove"
          @click.stop="onRemove(opt.value)"
        >
          <el-icon><Delete /></el-icon>
        </el-button>
      </div>
    </el-option>
  </el-select>
</template>

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Delete } from '@element-plus/icons-vue'
  import { ElMessage, ElMessageBox } from 'element-plus'
  import { useDictStore } from '@/store/modules/dict'

  const props = withDefaults(
    defineProps<{
      dictType: string
      modelValue?: string | null
      allowCreate?: boolean
      clearable?: boolean
      filterable?: boolean
      disabled?: boolean
      placeholder?: string
      size?: '' | 'default' | 'small' | 'large'
      style?: string | Record<string, string>
    }>(),
    {
      modelValue: '',
      allowCreate: true,
      clearable: true,
      filterable: true,
      disabled: false,
      placeholder: '请选择',
      size: 'default'
    }
  )

  const emit = defineEmits<{
    'update:modelValue': [value: string]
    change: [value: string]
  }>()

  const dictStore = useDictStore()
  const loading = ref(false)

  const options = computed(() => dictStore.getOptions(props.dictType))

  function isKnown(value: string) {
    return options.value.some((o) => o.value === value || o.label === value)
  }

  async function onSelect(raw: string | null | undefined) {
    const next = raw == null ? '' : String(raw).trim()
    if (!next) {
      emit('update:modelValue', '')
      emit('change', '')
      return
    }
    if (!props.allowCreate || isKnown(next)) {
      const known = options.value.find((o) => o.value === next || o.label === next)
      const value = known?.value ?? next
      emit('update:modelValue', value)
      emit('change', value)
      return
    }
    try {
      await ElMessageBox.confirm(
        `将「${next}」新增到字典,是否继续?`,
        '新增字典项',
        { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
      )
    } catch {
      return
    }
    loading.value = true
    try {
      const data = await dictStore.ensure(props.dictType, next)
      emit('update:modelValue', data.value)
      emit('change', data.value)
    } catch (e: any) {
      ElMessage.error(e?.message || '写入字典失败')
    } finally {
      loading.value = false
    }
  }

  async function onRemove(value: string) {
    try {
      await ElMessageBox.confirm(`确定从字典中删除「${value}」吗?`, '删除字典项', {
        type: 'warning',
        confirmButtonText: '删除',
        cancelButtonText: '取消'
      })
    } catch {
      return
    }
    loading.value = true
    try {
      await dictStore.ensureRemove(props.dictType, value)
      if (props.modelValue === value) {
        emit('update:modelValue', '')
        emit('change', '')
      }
      ElMessage.success('已删除')
    } catch (e: any) {
      ElMessage.error(e?.message || '删除失败')
    } finally {
      loading.value = false
    }
  }
</script>

<style scoped>
  .dict-select-option {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .dict-select-option__remove {
    flex-shrink: 0;
  }
</style>

若项目 Element Plus / 图标为 auto-import,可按周边组件习惯调整 Delete / ElMessage 导入(以同目录 DictTag 与业务弹窗为准)。

  • Step 2: 本地类型检查(可选)

Run:

cd web-admin && pnpm exec vue-tsc --noEmit --pretty false 2>&1 | head -40

Expected: 无 DictSelect 相关报错(全量可能仍有历史噪音)

  • Step 3: Commit(若允许)
git add web-admin/src/components/DictSelect/index.vue
git commit -m "$(cat <<'EOF'
feat: add DictSelect with confirm-create and session remove

EOF
)"

Task 6: 改造报价单 QuoteFormPanel

Files:

  • Modify: web-admin/src/views/crm/quote/components/QuoteFormPanel.vue

  • Step 1: 引入并替换三列

在 script 中:

import DictSelect from '@/components/DictSelect/index.vue'

将单位 / 包装规格 / 送货方式三列的 el-select 替换为:

<DictSelect
  v-model="scope.row.unit"
  dict-type="crm_product_unit"
  placeholder="请选择单位"
  size="small"
  style="width: 100%"
/>
<DictSelect
  v-model="scope.row.packageSpec"
  dict-type="crm_product_gg"
  placeholder="请选择包装规格"
  size="small"
  style="width: 100%"
/>
<DictSelect
  v-model="scope.row.deliveryMethod"
  dict-type="crm_shfs"
  placeholder="请选择送货方式"
  size="small"
  style="width: 100%"
/>
  • Step 2: 删除空壳 handler

删除仅做赋值的 handleUnitChange / handlePackageSpecChange(若无其它逻辑),并去掉模板上对应 @change。

  • Step 3: 手工点验清单(本页)
  1. 打开报价新增/编辑
  2. 手输新包装规格 → 确认 → 选中成功
  3. 取消确认 → 不选中
  4. 送货方式手输新值同样确认入库
  5. 本会话选项旁可删
  • Step 4: Commit(若允许)
git add web-admin/src/views/crm/quote/components/QuoteFormPanel.vue
git commit -m "$(cat <<'EOF'
feat(quote): use DictSelect for unit, package spec, delivery method

EOF
)"

Task 7: 改造合同表单(弹窗 + 独立页)

Files:

  • Modify: web-admin/src/views/crm/contract/components/ContractFormDialog.vue

  • Modify: web-admin/src/views/crm/contract/form.vue

  • Step 1: 两处均替换单位 / 包装规格 / 送货方式为 DictSelect

与 Task 6 相同三个 dict-type;合同单位原先无 allow-create,现一并支持。

  • Step 2: 删除空壳 handlePackageSpecChange 等

  • Step 3: 点验

合同新增弹窗与独立表单页各走一遍手输确认与会话删除。

  • Step 4: Commit(若允许)
git add web-admin/src/views/crm/contract/components/ContractFormDialog.vue web-admin/src/views/crm/contract/form.vue
git commit -m "$(cat <<'EOF'
feat(contract): use DictSelect for product dict fields

EOF
)"

Task 8: 改造运输计划产品表

Files:

  • Modify: web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue

  • Step 1: 在 editablePackageDelivery 分支替换单位 / 包装规格 / 送货方式

<DictSelect
  v-if="editablePackageDelivery"
  v-model="row.unit"
  dict-type="crm_product_unit"
  placeholder="请选择单位"
  class="pl-plan-product-cell-control"
  style="width: 100%"
/>

包装规格 / 送货方式同理(crm_product_gg / crm_shfs)。只读分支仍用 DictTag / tag 展示。

  • Step 2: 删除 handleUnitChange / handlePackageSpecChange(若仅赋值)

  • Step 3: 点验运输计划新建/编辑弹窗

  • Step 4: Commit(若允许)

git add web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue
git commit -m "$(cat <<'EOF'
feat(pl): use DictSelect on plan product unit, package, delivery

EOF
)"

Task 9: 改造客户 / 线索「所在园区」

Files:

  • Modify: web-admin/src/views/crm/customer/CustomerFormDialog.vue

  • Modify: web-admin/src/views/crm/clue/form.vue

  • Step 1: 园区字段改为 DictSelect

<DictSelect
  v-model="form.parkArea"
  dict-type="crm_customer_park"
  placeholder="请选择或输入所在园区"
/>
  • Step 2: 若 parkAreaOptions 仅服务于该下拉,删除相关 computed,改为依赖 dictStore

注意:若现有逻辑把字典项与其它本地选项合并,保留合并逻辑时改为基于 dictStore.getOptions('crm_customer_park'),或确认 DictSelect 足够后删合并。

  • Step 3: 客户与线索各点验一次手输园区

  • Step 4: Commit(若允许)

git add web-admin/src/views/crm/customer/CustomerFormDialog.vue web-admin/src/views/crm/clue/form.vue
git commit -m "$(cat <<'EOF'
feat(crm): use DictSelect for customer/clue park area

EOF
)"

Task 10: 回归验收 + 图谱更新

Files: 无新代码(或仅修验收中发现的小问题)

  • Step 1: 后端单测全绿
cd backend && source venv/bin/activate && pytest tests/test_dict_ensure.py -v

Expected: PASS

  • Step 2: 对照 Spec 手工验收

按 docs/superpowers/specs/2026-08-21-dict-select-ensure-design.md「测试要点」1–8 勾选。

额外:

  • 预置「自提」无删除按钮

  • 非白名单无法通过接口写入(可用 curl/Swagger 试 sys_user_sex)

  • 刷新后会话删除按钮消失,字典项仍可选(未删时)

  • Step 3: 更新知识图谱

./scripts/update-graph.sh all
# 或分别:
# ./scripts/update-graph.sh backend
# ./scripts/update-graph.sh frontend
  • Step 4: 将 Spec 状态改为已确认/已实现(可选)

把 docs/superpowers/specs/2026-08-21-dict-select-ensure-design.md 头部 待用户审阅 改为 已确认(实现完成后可再改为 已实现)。

  • Step 5: Commit(若允许)
git add docs/superpowers/specs/2026-08-21-dict-select-ensure-design.md graphify-out backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: mark dict-select ensure spec confirmed and refresh graphs

EOF
)"

Spec 覆盖自检

Spec 项 Task
白名单四类型 Task 1 + Task 4
ensure 立刻写入 + created 标志 Task 1、3、4
ensure-remove + create_by Task 2、3
二次确认 A' Task 5
本会话删除按钮 Task 4 sessionCreated + Task 5
报价/合同/计划/客户/线索 Task 6–9
送货方式 allow-create Task 6–8
非字典 allow-create 不改 无 Task(明确排除)
值班不强迁 无 Task

执行说明

  • Commit 步骤默认执行;若当前对话约定「未明确要求不提交」,实现时可跳过各 Task 的 Commit Step,改由用户一次性提交。
  • 实现时优先 subagent-driven-development(每 Task 一个子代理 + 复审)。