30 KiB
字典下拉手输即入库(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: 手工点验清单(本页)
- 打开报价新增/编辑
- 手输新包装规格 → 确认 → 选中成功
- 取消确认 → 不选中
- 送货方式手输新值同样确认入库
- 本会话选项旁可删
- 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 一个子代理 + 复审)。