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:
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user