feat(t207): audit local procurement results

This commit is contained in:
QiuSW
2026-07-27 12:23:05 +08:00
parent a00c1e3e17
commit 4533df7abc
37 changed files with 4004 additions and 115 deletions
@@ -756,6 +756,7 @@ type taskDetailView struct {
UpdatedAt time.Time
CanCancel bool
CancelRequiresAck bool
ExecutionReport *ExecutionReport
}
type taskDetailPage struct {
@@ -789,6 +790,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
CanCancel: canCancelTaskStatus(task.Status),
CancelRequiresAck: task.Status == "RUNNING" ||
task.Status == "WAITING_CONFIRMATION",
ExecutionReport: task.ExecutionReport,
}
}
@@ -45,6 +45,62 @@ textarea {
font: inherit;
}
.execution-audit {
margin-top: 20px;
}
.audit-summary {
margin-bottom: 18px;
}
.audit-list {
margin: 8px 0 0;
padding-left: 20px;
}
.audit-list li {
margin: 6px 0;
overflow-wrap: anywhere;
}
.audit-evidence-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.audit-evidence {
margin: 8px 0;
}
.audit-evidence img {
display: block;
width: 100%;
max-height: 240px;
object-fit: contain;
border: 1px solid var(--line);
background: var(--surface-soft);
}
.audit-evidence figcaption {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.audit-json {
max-height: 360px;
margin: 8px 0 16px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
background: var(--surface-soft);
font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
button,
input,
select {
@@ -73,6 +73,39 @@
</aside>
</div>
{{with .Task.ExecutionReport}}
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
<h2 id="execution-audit-heading">执行审计</h2>
{{if .Outcome}}
<dl class="definition-list audit-summary">
<dt>结果类型</dt><dd>{{.Outcome.ResultType}}</dd>
<dt>人工理由</dt><dd>{{if .Outcome.OperatorReason}}{{.Outcome.OperatorReason}}{{else}}未提供{{end}}</dd>
<dt>订单提交</dt><dd>{{if .Outcome.OrderSubmitted}}是{{else}}否{{end}}</dd>
{{if .Outcome.ErrorCode}}<dt>失败信息</dt><dd>{{.Outcome.ErrorCode}}:{{.Outcome.ErrorMessage}}</dd>{{end}}
</dl>
{{end}}
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
{{if .Evidence}}
<h3>证据截图</h3>
<div class="audit-evidence-grid">
{{range .Evidence}}<figure class="audit-evidence">
<img src="{{.ContentURL}}" alt="执行证据截图">
<figcaption>SHA-256 {{.SHA256}}({{.SizeBytes}} bytes){{if .ReceivedAfterExecutionExpiry}},授权到期后补报{{end}}</figcaption>
</figure>{{end}}
</div>
{{end}}
{{if .Events}}
<h3>执行事件</h3>
<ul class="audit-list">
{{range .Events}}<li><time datetime="{{machineTime .OccurredAt}}">{{displayTime .OccurredAt}}</time> · {{.Step}} · {{.Message}}{{if .ReceivedAfterExecutionExpiry}}(授权到期后补报){{end}}</li>{{end}}
</ul>
{{end}}
</section>
{{end}}
{{if .Task.CanCancel}}
<noscript>
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
@@ -58,6 +58,47 @@ type Task struct {
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
ExecutionReport *ExecutionReport
}
type ExecutionReport struct {
Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence
Mode string
SearchQuery string
Provenance string
Candidates string
Recommendation string
Outcome *ExecutionReportOutcome
}
type ExecutionReportEvent struct {
Step string
Type string
Message string
OccurredAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReportEvidence struct {
ID string
ContentURL string
SHA256 string
SizeBytes int64
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReportOutcome struct {
ResultType string
Outcome string
OperatorReason string
ErrorCode string
ErrorMessage string
ErrorStep string
OrderSubmitted bool
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type UploadReferenceInput struct {
@@ -2,7 +2,9 @@ package webui
import (
"context"
"encoding/json"
"errors"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
@@ -176,9 +178,82 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
func taskFromDetail(detail domain.TaskDetail) Task {
task := taskFromPurchase(detail.Task)
task.ReferenceAssetID = detail.Asset.ID
task.ExecutionReport = executionReportFrom(detail.Report)
return task
}
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
if report == nil {
return nil
}
result := &ExecutionReport{
Events: make([]ExecutionReportEvent, 0, len(report.Events)),
Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)),
}
for _, event := range report.Events {
result.Events = append(result.Events, ExecutionReportEvent{
Step: event.Step,
Type: event.Type,
Message: event.Message,
OccurredAt: event.OccurredAt,
ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry,
})
}
for _, evidence := range report.EvidenceAssets {
result.Evidence = append(result.Evidence, ExecutionReportEvidence{
ID: evidence.ID,
ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content",
SHA256: evidence.SHA256,
SizeBytes: evidence.SizeBytes,
CreatedAt: evidence.CreatedAt,
ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry,
})
}
if batch := report.CandidateBatch; batch != nil {
result.Mode = batch.ExecutionMode
result.SearchQuery = batch.SearchQuery
result.Provenance = prettyAuditJSON(batch.ProvenanceJSON)
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON)
}
if outcome := report.Outcome; outcome != nil {
result.Outcome = &ExecutionReportOutcome{
ResultType: outcome.ResultType,
Outcome: stringValue(outcome.Outcome),
OperatorReason: stringValue(outcome.OperatorReason),
ErrorCode: stringValue(outcome.ErrorCode),
ErrorMessage: stringValue(outcome.ErrorMessage),
ErrorStep: stringValue(outcome.ErrorStep),
OrderSubmitted: outcome.OrderSubmitted,
ReceivedAt: outcome.ReceivedAt,
ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry,
}
}
return result
}
func prettyAuditJSON(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return ""
}
var decoded any
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
return ""
}
formatted, err := json.MarshalIndent(decoded, "", " ")
if err != nil {
return ""
}
return string(formatted)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func mapUsecaseError(err error) error {
var typed *usecase.Error
if !errors.As(err, &typed) {