717 lines
17 KiB
Vue
717 lines
17 KiB
Vue
<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>
|