agentworkshop 0.7.61 → 0.7.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.AgentWorkShop/prompts/host-tools.json +1 -1
- package/.output/.AgentWorkShop/prompts/host-tools.json +1 -1
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{DKHA3XxF.js → B5RD2ZST.js} +1 -1
- package/.output/public/_nuxt/{CeEzUIvN.js → DAfWhONg.js} +2 -2
- package/.output/public/_nuxt/{analysis.DfMg76el.css → analysis.BwhKdel9.css} +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/603a62a6-71d7-4d9c-bb1d-e8264a58798e.json +1 -0
- package/.output/server/chunks/_/nitro.mjs +2176 -2173
- package/.output/server/chunks/_/nitro.mjs.map +1 -1
- package/.output/server/chunks/build/{analysis-MVEnlO7A.mjs → analysis-CPIxT0FP.mjs} +5 -5
- package/.output/server/chunks/build/{analysis-MVEnlO7A.mjs.map → analysis-CPIxT0FP.mjs.map} +1 -1
- package/.output/server/chunks/routes/renderer.mjs +1 -1
- package/.output/server/chunks/virtual/entry.mjs +1 -1
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/package.json +1 -1
- package/app/components/analysis/AnalysisEditModal.vue +192 -192
- package/package.json +1 -1
- package/server/api/workshop/analysis/[id].patch.ts +100 -100
- package/server/services/workshop/agents/industrial/daq-tools.ts +16 -7
- package/.output/public/_nuxt/builds/meta/eba8574d-1991-47df-944c-18608bb58181.json +0 -1
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* 分析报告人工修订弹窗(v27 编辑面):预填当前值 → PATCH → 父级刷新。
|
|
4
|
-
* 可见性由行级 canEdit(admin/产线 operate)控制;结论明细每行一条,
|
|
5
|
-
* 支撑指标为 JSON 文本(留空 = 保留原值;Agent 面仍无改删,修订全程审计)。
|
|
6
|
-
*/
|
|
7
|
-
import { reactive, watch } from 'vue'
|
|
8
|
-
import { ANALYSIS_CATEGORIES } from '@/app/pages/analysis/types'
|
|
9
|
-
import type { AnalysisReportView } from '@/app/pages/analysis/types'
|
|
10
|
-
|
|
11
|
-
const props = defineProps<{
|
|
12
|
-
/** 待编辑报告(null = 弹窗关闭) */
|
|
13
|
-
report: AnalysisReportView | null
|
|
14
|
-
saving: boolean
|
|
15
|
-
}>()
|
|
16
|
-
|
|
17
|
-
const emit = defineEmits<{
|
|
18
|
-
close: []
|
|
19
|
-
save: [patch: Record<string, unknown>]
|
|
20
|
-
}>()
|
|
21
|
-
|
|
22
|
-
const form = reactive({
|
|
23
|
-
title: '', summary: '', category: '', recipeId: '', recipeVersion: '',
|
|
24
|
-
windowFrom: '', windowTo: '', dataSources: '', conclusions: '', report: '', metrics: '',
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
watch(() => props.report, (r) => {
|
|
28
|
-
if (!r) return
|
|
29
|
-
form.title = r.title
|
|
30
|
-
form.summary = r.summary
|
|
31
|
-
form.category = r.category
|
|
32
|
-
form.recipeId = r.recipeId
|
|
33
|
-
form.recipeVersion = r.recipeVersion
|
|
34
|
-
form.windowFrom = r.windowFrom ?? ''
|
|
35
|
-
form.windowTo = r.windowTo ?? ''
|
|
36
|
-
form.dataSources = r.dataSources
|
|
37
|
-
form.conclusions = (r.conclusions ?? []).join('\n')
|
|
38
|
-
form.report = r.report ?? ''
|
|
39
|
-
form.metrics = Object.keys(r.metrics ?? {}).length ? JSON.stringify(r.metrics, null, 1) : ''
|
|
40
|
-
}, { immediate: true })
|
|
41
|
-
|
|
42
|
-
function submit(): void {
|
|
43
|
-
let metrics: unknown = undefined
|
|
44
|
-
if (form.metrics.trim()) {
|
|
45
|
-
try {
|
|
46
|
-
metrics = JSON.parse(form.metrics)
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
// JSON 非法直接留原值并由后端校验;此处不阻断
|
|
50
|
-
metrics = form.metrics
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
emit('save', {
|
|
54
|
-
title: form.title,
|
|
55
|
-
summary: form.summary,
|
|
56
|
-
category: form.category || 'general',
|
|
57
|
-
recipe_id: form.recipeId.trim(),
|
|
58
|
-
recipe_version: form.recipeVersion.trim(),
|
|
59
|
-
window_from: form.windowFrom.trim(),
|
|
60
|
-
window_to: form.windowTo.trim(),
|
|
61
|
-
data_sources: form.dataSources,
|
|
62
|
-
conclusions: form.conclusions,
|
|
63
|
-
report: form.report,
|
|
64
|
-
...(metrics !== undefined ? { metrics } : {}),
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
</script>
|
|
68
|
-
|
|
69
|
-
<template>
|
|
70
|
-
<a-modal
|
|
71
|
-
:open="Boolean(report)"
|
|
72
|
-
:title="$t('analysis.editTitle')"
|
|
73
|
-
:confirm-loading="saving"
|
|
74
|
-
:ok-text="$t('analysis.save')"
|
|
75
|
-
:cancel-text="$t('analysis.cancel')"
|
|
76
|
-
width="720px"
|
|
77
|
-
@ok="submit"
|
|
78
|
-
@cancel="emit('close')"
|
|
79
|
-
>
|
|
80
|
-
<div class="edit-form">
|
|
81
|
-
<label class="fld">
|
|
82
|
-
<span>{{ $t('analysis.thTitle') }}</span>
|
|
83
|
-
<input
|
|
84
|
-
v-model="form.title"
|
|
85
|
-
class="inp-sel"
|
|
86
|
-
>
|
|
87
|
-
</label>
|
|
88
|
-
<label class="fld">
|
|
89
|
-
<span>{{ $t('analysis.thSummary') }}</span>
|
|
90
|
-
<input
|
|
91
|
-
v-model="form.summary"
|
|
92
|
-
class="inp-sel"
|
|
93
|
-
>
|
|
94
|
-
</label>
|
|
95
|
-
<div class="fld-row">
|
|
96
|
-
<label class="fld">
|
|
97
|
-
<span>{{ $t('analysis.fCat') }}</span>
|
|
98
|
-
<select
|
|
99
|
-
v-model="form.category"
|
|
100
|
-
class="inp-sel"
|
|
101
|
-
>
|
|
102
|
-
<option
|
|
103
|
-
v-for="c in ANALYSIS_CATEGORIES"
|
|
104
|
-
:key="c"
|
|
105
|
-
:value="c"
|
|
106
|
-
>
|
|
107
|
-
{{ $t(`analysis.cat.${c}`) }}
|
|
108
|
-
</option>
|
|
109
|
-
</select>
|
|
110
|
-
</label>
|
|
111
|
-
<label class="fld">
|
|
112
|
-
<span>{{ $t('analysis.kRecipe') }}</span>
|
|
113
|
-
<input
|
|
114
|
-
v-model="form.recipeId"
|
|
115
|
-
class="inp-sel"
|
|
116
|
-
placeholder="rcp-…"
|
|
117
|
-
>
|
|
118
|
-
</label>
|
|
119
|
-
<label class="fld">
|
|
120
|
-
<span>{{ $t('analysis.kVersion') }}</span>
|
|
121
|
-
<input
|
|
122
|
-
v-model="form.recipeVersion"
|
|
123
|
-
class="inp-sel"
|
|
124
|
-
>
|
|
125
|
-
</label>
|
|
126
|
-
</div>
|
|
127
|
-
<div class="fld-row">
|
|
128
|
-
<label class="fld">
|
|
129
|
-
<span>{{ $t('analysis.kWindowFrom') }}</span>
|
|
130
|
-
<input
|
|
131
|
-
v-model="form.windowFrom"
|
|
132
|
-
class="inp-sel"
|
|
133
|
-
placeholder="ISO 8601"
|
|
134
|
-
>
|
|
135
|
-
</label>
|
|
136
|
-
<label class="fld">
|
|
137
|
-
<span>{{ $t('analysis.kWindowTo') }}</span>
|
|
138
|
-
<input
|
|
139
|
-
v-model="form.windowTo"
|
|
140
|
-
class="inp-sel"
|
|
141
|
-
placeholder="ISO 8601"
|
|
142
|
-
>
|
|
143
|
-
</label>
|
|
144
|
-
</div>
|
|
145
|
-
<label class="fld">
|
|
146
|
-
<span>{{ $t('analysis.kSources') }}</span>
|
|
147
|
-
<input
|
|
148
|
-
v-model="form.dataSources"
|
|
149
|
-
class="inp-sel"
|
|
150
|
-
>
|
|
151
|
-
</label>
|
|
152
|
-
<label class="fld">
|
|
153
|
-
<span>{{ $t('analysis.kConclusions') }}({{ $t('analysis.conclusionsHint') }})</span>
|
|
154
|
-
<textarea
|
|
155
|
-
v-model="form.conclusions"
|
|
156
|
-
class="inp-sel"
|
|
157
|
-
rows="4"
|
|
158
|
-
/>
|
|
159
|
-
</label>
|
|
160
|
-
<label class="fld">
|
|
161
|
-
<span>{{ $t('analysis.kReport') }}</span>
|
|
162
|
-
<textarea
|
|
163
|
-
v-model="form.report"
|
|
164
|
-
class="inp-sel mono"
|
|
165
|
-
rows="6"
|
|
166
|
-
/>
|
|
167
|
-
</label>
|
|
168
|
-
<label class="fld">
|
|
169
|
-
<span>{{ $t('analysis.kMetrics') }}(JSON)</span>
|
|
170
|
-
<textarea
|
|
171
|
-
v-model="form.metrics"
|
|
172
|
-
class="inp-sel mono"
|
|
173
|
-
rows="4"
|
|
174
|
-
/>
|
|
175
|
-
</label>
|
|
176
|
-
</div>
|
|
177
|
-
</a-modal>
|
|
178
|
-
</template>
|
|
179
|
-
|
|
180
|
-
<style scoped>
|
|
181
|
-
.edit-form { display: flex; flex-direction: column; gap: 10px; max-height: 64vh; overflow: auto; padding-right: 4px; }
|
|
182
|
-
.fld { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-faint); }
|
|
183
|
-
.fld-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
|
|
184
|
-
.inp-sel {
|
|
185
|
-
min-width: 0; padding: 6px 9px; font-size: 13px; color: var(--ink);
|
|
186
|
-
background: var(--frost-bg);
|
|
187
|
-
border: 1px solid var(--glass-line); border-radius: 7px; outline: none;
|
|
188
|
-
}
|
|
189
|
-
.inp-sel:focus { border-color: color-mix(in srgb, var(--tone-info-dot) 55%, transparent); }
|
|
190
|
-
textarea.inp-sel { resize: vertical; line-height: 1.6; }
|
|
191
|
-
.mono { font-family: var(--font-mono, monospace); font-size: 11.5px; }
|
|
192
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* 分析报告人工修订弹窗(v27 编辑面):预填当前值 → PATCH → 父级刷新。
|
|
4
|
+
* 可见性由行级 canEdit(admin/产线 operate)控制;结论明细每行一条,
|
|
5
|
+
* 支撑指标为 JSON 文本(留空 = 保留原值;Agent 面仍无改删,修订全程审计)。
|
|
6
|
+
*/
|
|
7
|
+
import { reactive, watch } from 'vue'
|
|
8
|
+
import { ANALYSIS_CATEGORIES } from '@/app/pages/analysis/types'
|
|
9
|
+
import type { AnalysisReportView } from '@/app/pages/analysis/types'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
/** 待编辑报告(null = 弹窗关闭) */
|
|
13
|
+
report: AnalysisReportView | null
|
|
14
|
+
saving: boolean
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
close: []
|
|
19
|
+
save: [patch: Record<string, unknown>]
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const form = reactive({
|
|
23
|
+
title: '', summary: '', category: '', recipeId: '', recipeVersion: '',
|
|
24
|
+
windowFrom: '', windowTo: '', dataSources: '', conclusions: '', report: '', metrics: '',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
watch(() => props.report, (r) => {
|
|
28
|
+
if (!r) return
|
|
29
|
+
form.title = r.title
|
|
30
|
+
form.summary = r.summary
|
|
31
|
+
form.category = r.category
|
|
32
|
+
form.recipeId = r.recipeId
|
|
33
|
+
form.recipeVersion = r.recipeVersion
|
|
34
|
+
form.windowFrom = r.windowFrom ?? ''
|
|
35
|
+
form.windowTo = r.windowTo ?? ''
|
|
36
|
+
form.dataSources = r.dataSources
|
|
37
|
+
form.conclusions = (r.conclusions ?? []).join('\n')
|
|
38
|
+
form.report = r.report ?? ''
|
|
39
|
+
form.metrics = Object.keys(r.metrics ?? {}).length ? JSON.stringify(r.metrics, null, 1) : ''
|
|
40
|
+
}, { immediate: true })
|
|
41
|
+
|
|
42
|
+
function submit(): void {
|
|
43
|
+
let metrics: unknown = undefined
|
|
44
|
+
if (form.metrics.trim()) {
|
|
45
|
+
try {
|
|
46
|
+
metrics = JSON.parse(form.metrics)
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// JSON 非法直接留原值并由后端校验;此处不阻断
|
|
50
|
+
metrics = form.metrics
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
emit('save', {
|
|
54
|
+
title: form.title,
|
|
55
|
+
summary: form.summary,
|
|
56
|
+
category: form.category || 'general',
|
|
57
|
+
recipe_id: form.recipeId.trim(),
|
|
58
|
+
recipe_version: form.recipeVersion.trim(),
|
|
59
|
+
window_from: form.windowFrom.trim(),
|
|
60
|
+
window_to: form.windowTo.trim(),
|
|
61
|
+
data_sources: form.dataSources,
|
|
62
|
+
conclusions: form.conclusions,
|
|
63
|
+
report: form.report,
|
|
64
|
+
...(metrics !== undefined ? { metrics } : {}),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<a-modal
|
|
71
|
+
:open="Boolean(report)"
|
|
72
|
+
:title="$t('analysis.editTitle')"
|
|
73
|
+
:confirm-loading="saving"
|
|
74
|
+
:ok-text="$t('analysis.save')"
|
|
75
|
+
:cancel-text="$t('analysis.cancel')"
|
|
76
|
+
width="720px"
|
|
77
|
+
@ok="submit"
|
|
78
|
+
@cancel="emit('close')"
|
|
79
|
+
>
|
|
80
|
+
<div class="edit-form">
|
|
81
|
+
<label class="fld">
|
|
82
|
+
<span>{{ $t('analysis.thTitle') }}</span>
|
|
83
|
+
<input
|
|
84
|
+
v-model="form.title"
|
|
85
|
+
class="inp-sel"
|
|
86
|
+
>
|
|
87
|
+
</label>
|
|
88
|
+
<label class="fld">
|
|
89
|
+
<span>{{ $t('analysis.thSummary') }}</span>
|
|
90
|
+
<input
|
|
91
|
+
v-model="form.summary"
|
|
92
|
+
class="inp-sel"
|
|
93
|
+
>
|
|
94
|
+
</label>
|
|
95
|
+
<div class="fld-row">
|
|
96
|
+
<label class="fld">
|
|
97
|
+
<span>{{ $t('analysis.fCat') }}</span>
|
|
98
|
+
<select
|
|
99
|
+
v-model="form.category"
|
|
100
|
+
class="inp-sel"
|
|
101
|
+
>
|
|
102
|
+
<option
|
|
103
|
+
v-for="c in ANALYSIS_CATEGORIES"
|
|
104
|
+
:key="c"
|
|
105
|
+
:value="c"
|
|
106
|
+
>
|
|
107
|
+
{{ $t(`analysis.cat.${c}`) }}
|
|
108
|
+
</option>
|
|
109
|
+
</select>
|
|
110
|
+
</label>
|
|
111
|
+
<label class="fld">
|
|
112
|
+
<span>{{ $t('analysis.kRecipe') }}</span>
|
|
113
|
+
<input
|
|
114
|
+
v-model="form.recipeId"
|
|
115
|
+
class="inp-sel"
|
|
116
|
+
placeholder="rcp-…"
|
|
117
|
+
>
|
|
118
|
+
</label>
|
|
119
|
+
<label class="fld">
|
|
120
|
+
<span>{{ $t('analysis.kVersion') }}</span>
|
|
121
|
+
<input
|
|
122
|
+
v-model="form.recipeVersion"
|
|
123
|
+
class="inp-sel"
|
|
124
|
+
>
|
|
125
|
+
</label>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="fld-row">
|
|
128
|
+
<label class="fld">
|
|
129
|
+
<span>{{ $t('analysis.kWindowFrom') }}</span>
|
|
130
|
+
<input
|
|
131
|
+
v-model="form.windowFrom"
|
|
132
|
+
class="inp-sel"
|
|
133
|
+
placeholder="ISO 8601"
|
|
134
|
+
>
|
|
135
|
+
</label>
|
|
136
|
+
<label class="fld">
|
|
137
|
+
<span>{{ $t('analysis.kWindowTo') }}</span>
|
|
138
|
+
<input
|
|
139
|
+
v-model="form.windowTo"
|
|
140
|
+
class="inp-sel"
|
|
141
|
+
placeholder="ISO 8601"
|
|
142
|
+
>
|
|
143
|
+
</label>
|
|
144
|
+
</div>
|
|
145
|
+
<label class="fld">
|
|
146
|
+
<span>{{ $t('analysis.kSources') }}</span>
|
|
147
|
+
<input
|
|
148
|
+
v-model="form.dataSources"
|
|
149
|
+
class="inp-sel"
|
|
150
|
+
>
|
|
151
|
+
</label>
|
|
152
|
+
<label class="fld">
|
|
153
|
+
<span>{{ $t('analysis.kConclusions') }}({{ $t('analysis.conclusionsHint') }})</span>
|
|
154
|
+
<textarea
|
|
155
|
+
v-model="form.conclusions"
|
|
156
|
+
class="inp-sel"
|
|
157
|
+
rows="4"
|
|
158
|
+
/>
|
|
159
|
+
</label>
|
|
160
|
+
<label class="fld">
|
|
161
|
+
<span>{{ $t('analysis.kReport') }}</span>
|
|
162
|
+
<textarea
|
|
163
|
+
v-model="form.report"
|
|
164
|
+
class="inp-sel mono"
|
|
165
|
+
rows="6"
|
|
166
|
+
/>
|
|
167
|
+
</label>
|
|
168
|
+
<label class="fld">
|
|
169
|
+
<span>{{ $t('analysis.kMetrics') }}(JSON)</span>
|
|
170
|
+
<textarea
|
|
171
|
+
v-model="form.metrics"
|
|
172
|
+
class="inp-sel mono"
|
|
173
|
+
rows="4"
|
|
174
|
+
/>
|
|
175
|
+
</label>
|
|
176
|
+
</div>
|
|
177
|
+
</a-modal>
|
|
178
|
+
</template>
|
|
179
|
+
|
|
180
|
+
<style scoped>
|
|
181
|
+
.edit-form { display: flex; flex-direction: column; gap: 10px; max-height: 64vh; overflow: auto; padding-right: 4px; }
|
|
182
|
+
.fld { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-faint); }
|
|
183
|
+
.fld-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
|
|
184
|
+
.inp-sel {
|
|
185
|
+
min-width: 0; padding: 6px 9px; font-size: 13px; color: var(--ink);
|
|
186
|
+
background: var(--frost-bg);
|
|
187
|
+
border: 1px solid var(--glass-line); border-radius: 7px; outline: none;
|
|
188
|
+
}
|
|
189
|
+
.inp-sel:focus { border-color: color-mix(in srgb, var(--tone-info-dot) 55%, transparent); }
|
|
190
|
+
textarea.inp-sel { resize: vertical; line-height: 1.6; }
|
|
191
|
+
.mono { font-family: var(--font-mono, monospace); font-size: 11.5px; }
|
|
192
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PATCH /api/workshop/analysis/:id —— 人工修订分析报告(v27 编辑面)。
|
|
3
|
-
* 权限:admin,或对该报告产线有「可操控」(operate)的用户;readonly/无授权 403。
|
|
4
|
-
* 可编辑字段:title/category/summary/conclusions/report/metrics/recipe_id/recipe_version/
|
|
5
|
-
* window_from/window_to/data_sources(缺省 = 保留原值);修订归因固化进 updated_*。
|
|
6
|
-
* Agent 面仍无改删 —— 编辑是带审计的人类管理操作(recordOps analysis.update)。
|
|
7
|
-
*/
|
|
8
|
-
import {
|
|
9
|
-
import { resolveUser } from '@/server/api/workshop/caller'
|
|
10
|
-
import { defineApiHandler } from '@/server/utils/response'
|
|
11
|
-
import { AppError } from '@/server/utils/errors'
|
|
12
|
-
import { lineMode } from '@/server/services/workshop/permissions'
|
|
13
|
-
import { getAnalysisRepo } from '@/server/services/workshop/analysis/analysis.repo'
|
|
14
|
-
import { getDcwRecipeRepo } from '@/server/services/workshop/dcw/dcw-recipe.repo'
|
|
15
|
-
import { recordOps } from '@/server/services/workshop/ops/ops'
|
|
16
|
-
|
|
17
|
-
export default defineApiHandler(async (event) => {
|
|
18
|
-
const user = resolveUser(event)
|
|
19
|
-
const id = getRouterParam(event, 'id')!
|
|
20
|
-
const repo = getAnalysisRepo()
|
|
21
|
-
const report = repo.get(id)
|
|
22
|
-
if (!report) throw new AppError(404, 'NOT_FOUND', `分析报告不存在: ${id}`)
|
|
23
|
-
|
|
24
|
-
// 编辑权限:admin 或产线 operate(readonly/无授权不可改)
|
|
25
|
-
const isAdmin = user.role === 'admin'
|
|
26
|
-
if (!isAdmin && (!report.lineId || lineMode(user, report.lineId) !== 'operate')) {
|
|
27
|
-
throw new AppError(403, 'ANALYSIS_FORBIDDEN', `无权修订该分析报告:需要产线「可操控」权限或 admin(报告产线 ${report.lineName || report.lineId || '-'})`)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const body = (await readBody<Record<string, unknown>>(event)) ?? {}
|
|
31
|
-
const str = (k: string): string | undefined => (body[k] === undefined ? undefined : String(body[k] ?? '').trim())
|
|
32
|
-
const title = str('title')
|
|
33
|
-
const summary = str('summary')
|
|
34
|
-
if (title !== undefined && !title) throw new AppError(400, 'VALIDATION_ERROR', 'title 不能为空')
|
|
35
|
-
if (summary !== undefined && !summary) throw new AppError(400, 'VALIDATION_ERROR', 'summary 不能为空')
|
|
36
|
-
|
|
37
|
-
// 配方校验(与保存面同口径:存在且归属该产线)
|
|
38
|
-
const recipeId = str('recipe_id')
|
|
39
|
-
if (recipeId) {
|
|
40
|
-
const recipe = getDcwRecipeRepo().list().find(r => r.id === recipeId)
|
|
41
|
-
if (!recipe) throw new AppError(404, 'NOT_FOUND', `配方不存在: ${recipeId}`)
|
|
42
|
-
if (recipe.lineId && report.lineId && recipe.lineId !== report.lineId) {
|
|
43
|
-
throw new AppError(400, 'VALIDATION_ERROR', `配方「${recipe.name}」不属于该报告的产线`)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// 时间窗归一:列内为本地 ISO(平台 toISOString 补丁),外部 Z 格式/epoch ms 归一后入库
|
|
48
|
-
const timeArg = (k: string): string | null | undefined => {
|
|
49
|
-
if (body[k] === undefined) return undefined
|
|
50
|
-
const raw = String(body[k] ?? '').trim()
|
|
51
|
-
if (!raw) return null
|
|
52
|
-
const ms = /^\d{10,}$/.test(raw) ? Number(raw) : Date.parse(raw)
|
|
53
|
-
if (!Number.isFinite(ms)) throw new AppError(400, 'VALIDATION_ERROR', `${k} 时间格式无效(ISO 8601 或 epoch ms)`)
|
|
54
|
-
return new Date(ms).toISOString()
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const conclusions = body.conclusions === undefined
|
|
58
|
-
? undefined
|
|
59
|
-
: (Array.isArray(body.conclusions) ? body.conclusions : String(body.conclusions ?? '').split('\n'))
|
|
60
|
-
.map((x: unknown) => String(x).replace(/^[-*\d.\s]+/, '').trim())
|
|
61
|
-
.filter(Boolean)
|
|
62
|
-
let metrics: Record<string, unknown> | undefined
|
|
63
|
-
if (body.metrics !== undefined && body.metrics !== null && String(body.metrics).trim() !== '') {
|
|
64
|
-
try {
|
|
65
|
-
metrics = typeof body.metrics === 'object' && !Array.isArray(body.metrics)
|
|
66
|
-
? (body.metrics as Record<string, unknown>)
|
|
67
|
-
: JSON.parse(String(body.metrics))
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
throw new AppError(400, 'VALIDATION_ERROR', 'metrics 需为 JSON 对象(或留空保留原值)')
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const updated = repo.update(id, {
|
|
75
|
-
title: title ?? undefined,
|
|
76
|
-
category: str('category') ?? undefined,
|
|
77
|
-
summary: summary ?? undefined,
|
|
78
|
-
conclusions,
|
|
79
|
-
report: body.report === undefined ? undefined : String(body.report ?? ''),
|
|
80
|
-
metrics,
|
|
81
|
-
recipeId,
|
|
82
|
-
recipeVersion: str('recipe_version'),
|
|
83
|
-
windowFrom: timeArg('window_from'),
|
|
84
|
-
windowTo: timeArg('window_to'),
|
|
85
|
-
dataSources: str('data_sources'),
|
|
86
|
-
}, { id: user.id, name: user.name })
|
|
87
|
-
if (!updated) throw new AppError(404, 'NOT_FOUND', `分析报告不存在: ${id}`)
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
recordOps({
|
|
91
|
-
actor: user.id, actorName: user.name, actorKind: 'user',
|
|
92
|
-
action: 'analysis.update', kind: 'analysis', targetKind: 'analysis', targetId: id,
|
|
93
|
-
lineId: updated.lineId, recipeId: updated.recipeId,
|
|
94
|
-
summary: `人工修订分析报告「${updated.title}」(产线「${updated.lineName || updated.lineId || '-'}」)`,
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
catch { /* 审计失败不阻断 */ }
|
|
98
|
-
|
|
99
|
-
return { report: updated }
|
|
100
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* PATCH /api/workshop/analysis/:id —— 人工修订分析报告(v27 编辑面)。
|
|
3
|
+
* 权限:admin,或对该报告产线有「可操控」(operate)的用户;readonly/无授权 403。
|
|
4
|
+
* 可编辑字段:title/category/summary/conclusions/report/metrics/recipe_id/recipe_version/
|
|
5
|
+
* window_from/window_to/data_sources(缺省 = 保留原值);修订归因固化进 updated_*。
|
|
6
|
+
* Agent 面仍无改删 —— 编辑是带审计的人类管理操作(recordOps analysis.update)。
|
|
7
|
+
*/
|
|
8
|
+
import { getRouterParam, readBody } from 'h3'
|
|
9
|
+
import { resolveUser } from '@/server/api/workshop/caller'
|
|
10
|
+
import { defineApiHandler } from '@/server/utils/response'
|
|
11
|
+
import { AppError } from '@/server/utils/errors'
|
|
12
|
+
import { lineMode } from '@/server/services/workshop/permissions'
|
|
13
|
+
import { getAnalysisRepo } from '@/server/services/workshop/analysis/analysis.repo'
|
|
14
|
+
import { getDcwRecipeRepo } from '@/server/services/workshop/dcw/dcw-recipe.repo'
|
|
15
|
+
import { recordOps } from '@/server/services/workshop/ops/ops'
|
|
16
|
+
|
|
17
|
+
export default defineApiHandler(async (event) => {
|
|
18
|
+
const user = resolveUser(event)
|
|
19
|
+
const id = getRouterParam(event, 'id')!
|
|
20
|
+
const repo = getAnalysisRepo()
|
|
21
|
+
const report = repo.get(id)
|
|
22
|
+
if (!report) throw new AppError(404, 'NOT_FOUND', `分析报告不存在: ${id}`)
|
|
23
|
+
|
|
24
|
+
// 编辑权限:admin 或产线 operate(readonly/无授权不可改)
|
|
25
|
+
const isAdmin = user.role === 'admin'
|
|
26
|
+
if (!isAdmin && (!report.lineId || lineMode(user, report.lineId) !== 'operate')) {
|
|
27
|
+
throw new AppError(403, 'ANALYSIS_FORBIDDEN', `无权修订该分析报告:需要产线「可操控」权限或 admin(报告产线 ${report.lineName || report.lineId || '-'})`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const body = (await readBody<Record<string, unknown>>(event)) ?? {}
|
|
31
|
+
const str = (k: string): string | undefined => (body[k] === undefined ? undefined : String(body[k] ?? '').trim())
|
|
32
|
+
const title = str('title')
|
|
33
|
+
const summary = str('summary')
|
|
34
|
+
if (title !== undefined && !title) throw new AppError(400, 'VALIDATION_ERROR', 'title 不能为空')
|
|
35
|
+
if (summary !== undefined && !summary) throw new AppError(400, 'VALIDATION_ERROR', 'summary 不能为空')
|
|
36
|
+
|
|
37
|
+
// 配方校验(与保存面同口径:存在且归属该产线)
|
|
38
|
+
const recipeId = str('recipe_id')
|
|
39
|
+
if (recipeId) {
|
|
40
|
+
const recipe = getDcwRecipeRepo().list().find(r => r.id === recipeId)
|
|
41
|
+
if (!recipe) throw new AppError(404, 'NOT_FOUND', `配方不存在: ${recipeId}`)
|
|
42
|
+
if (recipe.lineId && report.lineId && recipe.lineId !== report.lineId) {
|
|
43
|
+
throw new AppError(400, 'VALIDATION_ERROR', `配方「${recipe.name}」不属于该报告的产线`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 时间窗归一:列内为本地 ISO(平台 toISOString 补丁),外部 Z 格式/epoch ms 归一后入库
|
|
48
|
+
const timeArg = (k: string): string | null | undefined => {
|
|
49
|
+
if (body[k] === undefined) return undefined
|
|
50
|
+
const raw = String(body[k] ?? '').trim()
|
|
51
|
+
if (!raw) return null
|
|
52
|
+
const ms = /^\d{10,}$/.test(raw) ? Number(raw) : Date.parse(raw)
|
|
53
|
+
if (!Number.isFinite(ms)) throw new AppError(400, 'VALIDATION_ERROR', `${k} 时间格式无效(ISO 8601 或 epoch ms)`)
|
|
54
|
+
return new Date(ms).toISOString()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const conclusions = body.conclusions === undefined
|
|
58
|
+
? undefined
|
|
59
|
+
: (Array.isArray(body.conclusions) ? body.conclusions : String(body.conclusions ?? '').split('\n'))
|
|
60
|
+
.map((x: unknown) => String(x).replace(/^[-*\d.\s]+/, '').trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
let metrics: Record<string, unknown> | undefined
|
|
63
|
+
if (body.metrics !== undefined && body.metrics !== null && String(body.metrics).trim() !== '') {
|
|
64
|
+
try {
|
|
65
|
+
metrics = typeof body.metrics === 'object' && !Array.isArray(body.metrics)
|
|
66
|
+
? (body.metrics as Record<string, unknown>)
|
|
67
|
+
: JSON.parse(String(body.metrics))
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new AppError(400, 'VALIDATION_ERROR', 'metrics 需为 JSON 对象(或留空保留原值)')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const updated = repo.update(id, {
|
|
75
|
+
title: title ?? undefined,
|
|
76
|
+
category: str('category') ?? undefined,
|
|
77
|
+
summary: summary ?? undefined,
|
|
78
|
+
conclusions,
|
|
79
|
+
report: body.report === undefined ? undefined : String(body.report ?? ''),
|
|
80
|
+
metrics,
|
|
81
|
+
recipeId,
|
|
82
|
+
recipeVersion: str('recipe_version'),
|
|
83
|
+
windowFrom: timeArg('window_from'),
|
|
84
|
+
windowTo: timeArg('window_to'),
|
|
85
|
+
dataSources: str('data_sources'),
|
|
86
|
+
}, { id: user.id, name: user.name })
|
|
87
|
+
if (!updated) throw new AppError(404, 'NOT_FOUND', `分析报告不存在: ${id}`)
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
recordOps({
|
|
91
|
+
actor: user.id, actorName: user.name, actorKind: 'user',
|
|
92
|
+
action: 'analysis.update', kind: 'analysis', targetKind: 'analysis', targetId: id,
|
|
93
|
+
lineId: updated.lineId, recipeId: updated.recipeId,
|
|
94
|
+
summary: `人工修订分析报告「${updated.title}」(产线「${updated.lineName || updated.lineId || '-'}」)`,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
catch { /* 审计失败不阻断 */ }
|
|
98
|
+
|
|
99
|
+
return { report: updated }
|
|
100
|
+
})
|
|
@@ -8,9 +8,12 @@ import { getActiveLineRun } from '../../dcw/line-run'
|
|
|
8
8
|
import { getAgentNodeBindingRepo } from '../node-bindings.repo'
|
|
9
9
|
import { getDaqNodeRepo } from '../../daq/daq-node.repo'
|
|
10
10
|
import { getDcwController } from '../../dcw/dcw-controller'
|
|
11
|
+
import { channelBoundLineId } from './ops-tools'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* 数采目标集解析(工具共用鉴权面):
|
|
14
|
+
* 数采目标集解析(工具共用鉴权面):
|
|
15
|
+
* ① Agent 绑定(kind=daq);② v28 频道绑线只读扩权 —— 频道绑定产线下的数采节点
|
|
16
|
+
* 并入读取集(与 ops_log/line_context 的绑线只读扩权同族;本函数仅服务只读查询面)。
|
|
14
17
|
* node_id/line_id 只能缩小范围,不能放大(越权节点一律拒绝)。
|
|
15
18
|
*/
|
|
16
19
|
export function daqTargetsOf(agentId: string, nodeIdArg: unknown, lineIdArg: unknown):
|
|
@@ -18,23 +21,29 @@ export function daqTargetsOf(agentId: string, nodeIdArg: unknown, lineIdArg: unk
|
|
|
18
21
|
| { error: { text: string, isError: true } } {
|
|
19
22
|
const repo = getAgentNodeBindingRepo()
|
|
20
23
|
const daqBindings = repo.byAgent(agentId).filter(b => b.kind === 'daq')
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
// v28:频道绑线的数采节点并入可见集(只读)
|
|
25
|
+
const boundLine = channelBoundLineId(agentId)
|
|
26
|
+
const boundLineNodes = boundLine
|
|
27
|
+
? getDaqNodeRepo().all().filter(n => n.lineId === boundLine).map(n => n.id)
|
|
28
|
+
: []
|
|
29
|
+
const readable = new Set([...daqBindings.map(b => b.nodeId), ...boundLineNodes])
|
|
30
|
+
if (readable.size === 0) {
|
|
31
|
+
return { error: { text: '你尚未绑定任何数采节点,频道也未绑定产线,无权查询采集数据。请在数字孪生界面绑定数采节点,或将频道绑定到目标产线(绑线后可只读查询该线数采数据)。', isError: true } }
|
|
23
32
|
}
|
|
24
33
|
const wanted = nodeIdArg ? String(nodeIdArg) : ''
|
|
25
|
-
if (wanted && !
|
|
26
|
-
return { error: { text: `无权查询节点 ${wanted}。你有权访问的数采节点:${
|
|
34
|
+
if (wanted && !readable.has(wanted)) {
|
|
35
|
+
return { error: { text: `无权查询节点 ${wanted}。你有权访问的数采节点:${[...readable].join(', ')}`, isError: true } }
|
|
27
36
|
}
|
|
28
37
|
const lineFilter = lineIdArg ? String(lineIdArg).trim() : ''
|
|
29
38
|
const lineOf = (id: string): string => getDaqNodeRepo().byId(id)?.lineId ?? ''
|
|
30
39
|
if (lineFilter && wanted && lineOf(wanted) !== lineFilter) {
|
|
31
40
|
return { error: { text: `节点 ${wanted} 不属于产线 ${lineFilter}(实际归属:${lineOf(wanted) || '未分配'}),line_id 与 node_id 过滤冲突。`, isError: true } }
|
|
32
41
|
}
|
|
33
|
-
let targets = wanted ? [wanted] :
|
|
42
|
+
let targets = wanted ? [wanted] : [...readable]
|
|
34
43
|
if (lineFilter && !wanted) {
|
|
35
44
|
const onLine = targets.filter(id => lineOf(id) === lineFilter)
|
|
36
45
|
if (onLine.length === 0) {
|
|
37
|
-
return { error: { text:
|
|
46
|
+
return { error: { text: `你有权访问的数采节点中没有归属产线 ${lineFilter} 的(各节点归属:${[...readable].map(id => `${id}=${lineOf(id) || '未分配'}`).join('; ')})。`, isError: true } }
|
|
38
47
|
}
|
|
39
48
|
targets = onLine
|
|
40
49
|
}
|