dieya_risk_prediction_frontend/src/pages/RiskAssistantPage.vue
kx_guo 1cc6b471d3 按原型统一风险报告页面与下载内容
完善报告页栏目和原型样式,统一风险卡片、网页报告、PDF与Word的字段口径;接入真实PDF和DOCX下载,修复合作频次、七维结论及标准判定展示差异。
2026-09-08 11:59:10 +08:00

717 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import axios from "axios";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue";
import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue";
import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue";
import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia";
import type { RiskAssessmentResult } from "@/types/risk";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const conversationStore = useConversationStore();
const {
current: conversation,
sending: loading,
error,
} = storeToRefs(conversationStore);
const prompt = ref("");
const ocrOpen = ref(false);
const scrollArea = ref<HTMLElement | null>(null);
const tool = ref<
| "assessment"
| "supplier"
| "material"
| "cases"
| "solution"
| "ocr"
| "voice"
| null
>(null);
const toolInput = ref("");
const toolError = ref("");
const toolFile = ref<File | null>(null);
const assessmentFields = ref({
category: "",
specification: "",
packaging: "",
supplier: "",
factory: "",
stage: "产品开发",
});
const toolTitle = computed(() => {
const titles = {
assessment: "评估新品",
supplier: "查供应商",
material: "查物料",
cases: "搜索相似案例",
solution: "生成 AI 方案",
ocr: "OCR 识别录入",
voice: "语音录入异常",
} as const;
return tool.value ? titles[tool.value] : "";
});
const toolLabel = computed(() => {
if (tool.value === "ocr") return "检测报告图片或 PDF(当前先提交描述)";
if (tool.value === "voice") return "录音内容(当前先提交转写文本)";
if (tool.value === "assessment") return "产品品类、规格、包装和供应商";
if (tool.value === "supplier") return "供应商名称";
if (tool.value === "material") return "物料名称";
if (tool.value === "cases") return "案例关键词";
return "方案主题";
});
const loadingText = computed(() =>
loading.value ? "正在调用风险评分引擎…" : "",
);
const canMonitor = computed(
() => auth.user?.role === "admin" || auth.user?.role === "manager",
);
onMounted(async () => {
await conversationStore.loadList();
await openFromRoute();
});
watch(
() => route.query.conversation,
async () => {
await openFromRoute();
},
);
function usePrompt(value: string) {
prompt.value = value;
void submit();
}
function openTool(
value:
| "assessment"
| "supplier"
| "material"
| "cases"
| "solution"
| "review"
| "ocr"
| "voice",
) {
if (value === "review") {
router.push({ name: "review" });
return;
}
if (value === "ocr") {
ocrOpen.value = true;
return;
}
tool.value = value;
toolInput.value = "";
toolFile.value = null;
toolError.value = "";
assessmentFields.value = {
category: "",
specification: "",
packaging: "",
supplier: "",
factory: "",
stage: "产品开发",
};
}
function closeTool() {
tool.value = null;
toolError.value = "";
}
function submitTool() {
const selectedTool = tool.value;
const selectedTitle = toolTitle.value;
if (selectedTool === "assessment") {
const fields = assessmentFields.value;
if (!fields.category.trim() || !fields.packaging.trim()) {
toolError.value = "产品品类和包装材质为必填项";
return;
}
toolInput.value = [
`评估新品:${fields.category}`,
fields.specification && `规格:${fields.specification}`,
`包装材质:${fields.packaging}`,
fields.supplier && `供应商:${fields.supplier}`,
fields.factory && `加工厂:${fields.factory}`,
`开发阶段:${fields.stage}`,
]
.filter(Boolean)
.join(",");
}
const finalValue = toolInput.value.trim();
if (!finalValue && !toolFile.value) {
toolError.value = "请先填写必填内容";
return;
}
closeTool();
prompt.value =
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`;
const messageType = toolFile.value
? selectedTool === "ocr"
? "image"
: "audio"
: "text";
void submit(messageType);
}
function selectFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0] ?? null;
if (file && file.size > 10 * 1024 * 1024) {
toolFile.value = null;
toolError.value = "文件不能超过 10MB";
return;
}
toolError.value = "";
toolFile.value = file;
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function loadOlder() {
const node = scrollArea.value;
const before = node?.scrollHeight ?? 0;
const beforeTop = node?.scrollTop ?? 0;
void conversationStore.loadOlder().then(async (loaded) => {
if (loaded && node) {
await nextTick();
node.scrollTop = node.scrollHeight - before + beforeTop;
}
});
}
function handleScroll() {
if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
}
async function openFromRoute() {
const rawId = route.query.conversation;
if (typeof rawId !== "string") return;
const id = Number(rawId);
if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id !== id) {
try {
await conversationStore.open(id);
await scrollToBottom("auto");
} catch (reason) {
if (axios.isAxiosError(reason) && reason.response?.status === 404) {
// A conversation can be deleted from another session while its URL remains open.
conversationStore.reset();
await router.replace({ name: "assessment", query: {} });
}
}
}
}
function openReport(result: RiskAssessmentResult) {
sessionStorage.setItem("dieya-risk-report", JSON.stringify(result));
void router.push({ name: "report" });
}
async function submit(
messageType: "text" | "image" | "file" | "audio" = "text",
) {
const value = prompt.value.trim();
if (!value || loading.value) return;
prompt.value = "";
await conversationStore.streamSend(
value,
(delta) => {
void delta;
},
messageType,
);
if (
conversation.value &&
route.query.conversation !== String(conversation.value.id)
) {
await router.replace({
name: "assessment",
query: { conversation: String(conversation.value.id) },
});
}
await scrollToBottom();
}
async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
await nextTick();
scrollArea.value?.scrollTo({
top: scrollArea.value.scrollHeight,
behavior,
});
}
</script>
<template>
<div class="assistant-page">
<div
ref="scrollArea"
class="conversation-scroll"
@scroll.passive="handleScroll"
>
<div class="conversation-column">
<p v-if="conversationStore.loadingOlder" class="history-loading">
正在加载更早消息…
</p>
<AssistantWelcome
v-if="!conversation?.messages.length"
:can-upload="auth.hasPermission('risk_item.submit')"
@select="usePrompt"
/>
<template
v-for="message in conversation?.messages ?? []"
:key="message.id"
>
<div class="message" :class="`message--${message.role}`">
<div class="message-avatar">
{{
message.role === "user"
? (auth.user?.name?.slice(0, 1) ?? "我")
: "蝶"
}}
</div>
<div v-if="message.role === 'user'" class="user-bubble">
{{ message.content }}
<time>{{ formatMessageTime(message.createdAt) }}</time>
</div>
<div v-else class="message-body">
<div
v-if="
message.semantic &&
!message.riskResult &&
!message.responseCard
"
class="semantic-strip"
>
已识别:{{ message.semantic.intent ?? "通用咨询" }}
<span v-if="message.semantic.category"
>· {{ message.semantic.category }}</span
>
<span v-if="message.semantic.packaging"
>· {{ message.semantic.packaging }}</span
>
</div>
<div
v-if="
message.content &&
!message.riskResult &&
!message.responseCard
"
class="assistant-bubble"
>
{{ message.content }}
</div>
<RiskAnalysisCard
v-if="message.riskResult"
:result="message.riskResult"
:can-monitor="canMonitor"
@generate-report="openReport(message.riskResult)"
/>
<AssistantResponseCard
v-if="message.responseCard"
:card="message.responseCard"
/>
<time class="message-time">{{
formatMessageTime(message.createdAt)
}}</time>
</div>
</div>
</template>
<div v-if="loading" class="message message--assistant">
<div class="message-avatar">蝶</div>
<div class="message-body">
<p class="state-message">{{ loadingText }}</p>
</div>
</div>
<p v-if="error" class="state-message state-message--error">
{{ error }}
</p>
</div>
</div>
<AssistantComposer
v-model="prompt"
:loading="loading"
:can-upload="auth.hasPermission('risk_item.submit')"
@submit="submit"
@select="usePrompt"
@tool="openTool"
/>
<div v-if="tool" class="tool-backdrop" @click.self="closeTool">
<form class="tool-dialog" @submit.prevent="submitTool">
<button
class="dialog-close"
type="button"
aria-label="关闭"
title="关闭"
@click="closeTool"
>
×
</button>
<p class="eyebrow">CHAT TOOL</p>
<h2>{{ toolTitle }}</h2>
<p class="dialog-description">
填写必要信息后,将生成一条带上下文的对话请求。
</p>
<div v-if="tool === 'assessment'" class="assessment-fields">
<label
>产品品类<input v-model="assessmentFields.category" autofocus
/></label>
<label
>规格容量<input
v-model="assessmentFields.specification"
placeholder="如 500ml / 100g"
/></label>
<label
>包装材质<input
v-model="assessmentFields.packaging"
placeholder="如 瓶子 / 软管 / 泵头"
/></label>
<label>供应商<input v-model="assessmentFields.supplier" /></label>
<label>加工厂<input v-model="assessmentFields.factory" /></label>
<label
>开发阶段<select v-model="assessmentFields.stage">
<option>需求发现</option>
<option>产品立项</option>
<option>产品开发</option>
<option>量产上市</option>
<option>产品生命周期</option>
</select></label
>
</div>
<label v-else>
{{ toolLabel }}
<input
v-if="tool !== 'ocr' && tool !== 'voice'"
v-model="toolInput"
:maxlength="tool === 'cases' ? 50 : 2000"
autofocus
/>
<input
v-else
type="file"
:accept="
tool === 'ocr'
? 'image/jpeg,image/png,application/pdf'
: 'audio/*'
"
@change="selectFile"
/>
</label>
<p v-if="toolError" class="dialog-error">{{ toolError }}</p>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="closeTool">
取消
</button>
<button type="submit" class="dialog-primary">带入对话</button>
</div>
</form>
</div>
<OcrRecognitionDialog :open="ocrOpen" @close="ocrOpen = false" />
</div>
</template>
<style scoped>
.assistant-page {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
background: var(--color-bg);
}
.conversation-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
}
.conversation-column {
display: flex;
width: min(var(--content-width), 100%);
margin: 0 auto;
padding: 0 24px 28px;
flex-direction: column;
gap: 18px;
}
.history-loading {
align-self: center;
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.4;
}
.message {
display: flex;
gap: 10px;
}
.message--user {
flex-direction: row-reverse;
}
.message-avatar {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
border-radius: 6px;
background: var(--color-text);
color: white;
font-size: 11px;
font-weight: 650;
}
.message--user .message-avatar {
border: 1px solid var(--color-border);
background: var(--color-bg-warm);
color: var(--color-text-muted);
}
.user-bubble {
max-width: 82%;
border-radius: 14px 4px 14px 14px;
padding: 11px 15px;
background: var(--color-text);
color: white;
font-size: 13px;
line-height: 1.65;
}
.user-bubble time,
.message-time {
display: block;
margin-top: 4px;
color: var(--color-text-faint);
font-size: 10px;
}
.user-bubble time {
color: rgb(255 255 255 / 70%);
text-align: right;
}
.assistant-bubble {
width: fit-content;
max-width: 82%;
border: 1px solid var(--color-border);
border-radius: 4px 14px 14px 14px;
padding: 11px 15px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.65;
}
.semantic-strip {
margin-bottom: 8px;
color: var(--color-text-muted);
font-size: 11px;
}
.message-body {
width: min(100%, 680px);
min-width: 0;
}
.assistant-placeholder {
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 20px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.placeholder-heading {
display: flex;
align-items: center;
gap: 11px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 13px;
}
.placeholder-signal {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-border-strong);
box-shadow: 0 0 0 5px var(--color-bg-warm);
}
.placeholder-heading > div {
display: grid;
}
.placeholder-heading strong {
font-size: 14px;
}
.placeholder-heading small,
.assistant-placeholder p {
color: var(--color-text-faint);
font-size: 10px;
}
.assistant-placeholder p {
margin: 13px 0 0;
}
.state-message {
margin: 8px 0 0;
color: var(--color-text-muted);
font-size: 11px;
}
.state-message--error {
color: var(--color-danger);
}
.tool-backdrop {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgb(17 17 17 / 28%);
}
.tool-dialog {
position: relative;
width: min(560px, 100%);
max-height: 80vh;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 24px;
background: var(--color-surface);
box-shadow: var(--shadow-lg);
}
.dialog-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: var(--color-text-muted);
font-size: 22px;
}
.tool-dialog h2 {
margin: 5px 0;
font-size: 20px;
}
.dialog-description {
margin: 0 0 20px;
color: var(--color-text-muted);
font-size: 12px;
}
.tool-dialog label {
display: grid;
gap: 7px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
.tool-dialog input {
width: 100%;
box-sizing: border-box;
}
.assessment-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.assessment-fields select {
min-height: 38px;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 0 10px;
background: white;
color: var(--color-text);
font: inherit;
}
.dialog-error {
margin: 8px 0 0;
color: var(--color-danger);
font-size: 12px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 22px;
}
.dialog-actions button {
min-height: 36px;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 0 14px;
font-size: 12px;
}
.dialog-secondary {
background: var(--color-surface);
color: var(--color-text-secondary);
}
.dialog-primary {
border-color: var(--color-text) !important;
background: var(--color-text);
color: white;
}
@media (max-width: 760px) {
.conversation-column {
padding-inline: 12px;
}
.message-avatar {
display: none;
}
.message-body,
.user-bubble {
width: 100%;
max-width: 100%;
}
.assessment-fields {
grid-template-columns: 1fr;
}
}
</style>