dev #1

Merged
kx_guo merged 31 commits from dev into main 2026-09-21 10:50:45 +08:00
5 changed files with 428 additions and 27 deletions
Showing only changes of commit d04e8888cb - Show all commits

View File

@ -1,6 +1,6 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type { Conversation, ConversationSummary } from "@/types/conversation";
import type { ApiResponse } from "@/types/api";
export interface ConversationStreamEvent {
type: "start" | "semantic" | "delta" | "done" | "error";
@ -22,6 +22,10 @@ export function createConversation(title?: string) {
});
}
export function directConversationQuery(kind: "supplier" | "material" | "cases" | "solution", query: string) {
return http.post<ApiResponse<Record<string, unknown>>>("/v1/conversations/direct-query", { kind, query });
}
export function fetchConversation(id: number, offset = 0) {
return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, {
params: { limit: 20, offset },

View File

@ -47,6 +47,7 @@ const functions: Array<{
icon: typeof Search;
tone: string;
prompt?: string;
isNew?: boolean;
tool:
"assessment" | "supplier" | "material" | "cases" | "solution" | "review";
}> = [
@ -89,6 +90,7 @@ const functions: Array<{
tone: "purple",
prompt: "帮我生成一个新品风险预防方案",
tool: "solution",
isNew: true,
},
{
title: "审核队列",
@ -96,6 +98,7 @@ const functions: Array<{
icon: ClipboardCheck,
tone: "red",
tool: "review",
isNew: true,
},
];
</script>
@ -155,14 +158,11 @@ const functions: Array<{
:key="item.title"
class="function-card"
type="button"
@click="
item.tool === 'assessment' || !item.prompt
? emit('tool', item.tool)
: emit('select', item.prompt)
"
@click="emit('tool', item.tool)"
>
<span class="function-dot" :class="`function-dot--${item.tone}`"></span>
<component :is="item.icon" :size="13" />
<span v-if="item.isNew" class="function-new">新</span>
<span
><strong>{{ item.title }}</strong
><small>{{ item.description }}</small></span
@ -253,8 +253,8 @@ const functions: Array<{
.function-grid {
display: grid;
max-width: var(--content-width);
margin: 8px auto 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin: 10px auto 0;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
@ -264,18 +264,22 @@ const functions: Array<{
min-height: 44px;
align-items: center;
gap: 6px;
position: relative;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 9px;
padding: 7px 8px;
border-radius: 8px;
padding: 8px 10px;
background: var(--color-surface);
color: var(--color-text-secondary);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.function-card:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.function-card > span:last-child {
@ -292,12 +296,26 @@ const functions: Array<{
.function-card strong {
color: var(--color-text);
font-size: 10px;
font-size: 11px;
font-weight: 600;
}
.function-card small {
color: var(--color-text-faint);
font-size: 9px;
margin-top: 1px;
}
.function-new {
position: absolute;
top: 2px;
right: 2px;
border-radius: 3px;
padding: 0 4px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 8px;
font-weight: 700;
}
.function-dot {

View File

@ -7,8 +7,10 @@ import {
Building2,
CheckCircle2,
ClipboardCheck,
ChevronDown,
PackageSearch,
Ruler,
Search,
Sparkles,
} from "@lucide/vue";
import { computed, ref } from "vue";
@ -24,6 +26,7 @@ const props = defineProps<{
const router = useRouter();
const emit = defineEmits<{
generateReport: [card: ReportCard];
openAssessment: [];
}>();
interface CardData extends AssistantResponseCard {
name?: string;
@ -42,7 +45,6 @@ interface CardData extends AssistantResponseCard {
suggestions?: string[];
note?: string;
detectedValues?: Array<{ name: string; value: string }>;
matchedCount?: number;
standards?: Array<{
category: string;
riskPoint: string;
@ -50,7 +52,17 @@ interface CardData extends AssistantResponseCard {
source?: string | null;
status: string;
}>;
cases?: Array<{
caseId?: number | null;
date?: string | null;
product?: string | null;
problem?: string | null;
solution?: string | null;
similarity?: number | null;
}>;
matchedCount?: number;
confidence?: string;
thinkingSummary?: string[];
analysisBase?: string;
risks?: Array<{ level: string; title: string; detail: string }>;
similarCases?: Array<{
@ -79,6 +91,7 @@ interface CardData extends AssistantResponseCard {
const data = computed(() => props.card as CardData);
type SolutionCase = NonNullable<CardData["similarCases"]>[number];
const selectedCase = ref<SolutionCase | null>(null);
const thinkingExpanded = ref(false);
const standardCategories = computed(() =>
[...new Set((data.value.standards ?? []).map((item) => item.category))].join(
" / ",
@ -135,6 +148,7 @@ function generateReport() {
<PackageSearch v-else-if="card.type === 'material'" :size="16" />
<BookOpenCheck v-else-if="card.type === 'standard'" :size="16" />
<ClipboardCheck v-else-if="card.type === 'compliance'" :size="16" />
<Search v-else-if="card.type === 'cases'" :size="22" />
<Bot v-else :size="16" />
</span>
<div>
@ -151,11 +165,17 @@ function generateReport() {
<small v-else-if="card.type === 'solution'"
>AI 推理生成 · 置信度:{{ data.confidence }}</small
>
<small v-else-if="card.type === 'cases'"
>匹配 {{ data.matchedCount ?? data.cases?.length ?? 0 }} 条相似历史案例</small
>
<small v-else>标准库规则匹配 · 待品控确认</small>
</div>
<span v-if="card.type === 'solution'" class="solution-confidence"
>置信度:{{ data.confidence || "中" }}</span
>
<span v-else-if="card.type === 'cases'" class="case-semantic-badge"
>语义检索</span
>
</header>
<template v-if="card.type === 'supplier'">
@ -273,7 +293,61 @@ function generateReport() {
<p v-if="data.note" class="card-note">{{ data.note }}</p>
</template>
<template v-else-if="card.type === 'cases'">
<section class="case-results">
<h4>相似案例 TOP {{ Math.min(3, data.cases?.length ?? 0) }}</h4>
<template v-if="data.cases?.length">
<button
v-for="(item, index) in data.cases.slice(0, 3)"
:key="`${item.caseId}-${index}`"
class="case-result-row"
type="button"
title="查看案例详情"
@click="openSimilarCase(item)"
>
<span class="case-result-rank">TOP {{ index + 1 }}</span>
<span class="case-result-text"
>{{ item.date || "日期未知" }}|{{ item.product || "未命名产品" }}|{{
item.problem || "异常问题未填写"
}} → {{ item.solution || "处理方案未填写" }}</span
>
<em v-if="item.similarity != null"
>相似度 {{ Math.round(item.similarity * 100) }}%</em
>
</button>
</template>
<p v-else class="empty">暂无匹配案例</p>
</section>
<section v-if="data.suggestions?.length" class="case-suggestions">
<h4>💡 处理建议</h4>
<ol>
<li v-for="suggestion in data.suggestions" :key="suggestion">{{ suggestion }}</li>
</ol>
</section>
</template>
<template v-else>
<section
v-if="data.thinkingSummary?.length"
class="solution-thinking"
>
<button
type="button"
class="solution-thinking__header"
@click="thinkingExpanded = !thinkingExpanded"
>
<span class="solution-thinking__title">思考过程</span>
<span class="solution-thinking__status">已完成</span>
<ChevronDown
:size="13"
class="solution-thinking__arrow"
:class="{ expanded: thinkingExpanded }"
/>
</button>
<ol v-if="thinkingExpanded" class="solution-thinking__steps">
<li v-for="step in data.thinkingSummary" :key="step">{{ step }}</li>
</ol>
</section>
<div class="solution-base">
<b>分析基础:</b
>{{ data.analysisBase || "基于相似品类、相似物料与标准库规则生成。" }}
@ -393,9 +467,20 @@ function generateReport() {
<CheckCircle2 :size="14" />追加到报告
</button>
<button
v-if="card.type === 'cases'"
type="button"
:disabled="card.type !== 'solution'"
:title="card.type === 'solution' ? '跳转判定标准库' : '功能开发中'"
class="response-actions__primary"
@click="emit('openAssessment')"
>
评估关联风险
</button>
<button v-if="card.type === 'cases'" type="button" @click="openStandardLibrary">
查看标准
</button>
<button
v-if="card.type === 'solution'"
type="button"
title="跳转判定标准库"
@click="openStandardLibrary"
>
查看标准详情
@ -523,6 +608,9 @@ function generateReport() {
.response-card--material {
border-left: 3px solid var(--color-warning);
}
.response-card--cases {
border-color: var(--color-border);
}
.response-card--standard,
.response-card--compliance {
border-left: 3px solid var(--color-accent);
@ -571,6 +659,35 @@ function generateReport() {
background: linear-gradient(135deg, var(--color-accent), var(--color-purple));
color: #fff;
}
.response-card--cases .response-card__icon {
width: 34px;
height: 34px;
color: #111827;
background: transparent;
}
.response-card--cases .response-card__header {
position: relative;
align-items: flex-start;
padding-bottom: 16px;
}
.response-card--cases .response-card__header h3 {
color: var(--color-warning);
font-size: 14px;
}
.response-card--cases .response-card__header small {
display: block;
margin-top: 4px;
color: var(--color-text-secondary);
}
.case-semantic-badge {
margin-left: auto;
border-radius: 999px;
padding: 4px 9px;
background: #eff6ff;
color: #2563eb;
font-size: 10px;
white-space: nowrap;
}
.response-card--solution .response-card__header {
margin-bottom: 14px;
border-bottom: 0;
@ -735,6 +852,77 @@ function generateReport() {
min-width: 0;
flex: 1;
}
.case-result-row {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
margin-top: 5px;
border: 0;
border-radius: 6px;
padding: 7px 9px;
background: #f5f5f4;
color: var(--color-text-secondary);
font: inherit;
line-height: 1.4;
text-align: left;
cursor: pointer;
}
.case-result-row:hover {
background: #e7e5e4;
}
.case-result-rank {
flex: 0 0 auto;
border-radius: 999px;
padding: 2px 6px;
background: #eff6ff;
color: #2563eb;
font-size: 9px;
white-space: nowrap;
}
.case-result-row > div {
min-width: 0;
flex: 1;
}
.case-result-text {
min-width: 0;
flex: 1;
font-size: 11px;
overflow-wrap: anywhere;
}
.case-result-row em {
flex: 0 0 auto;
color: var(--color-accent);
font-size: 10px;
font-style: normal;
white-space: nowrap;
}
.case-suggestions {
margin-top: 15px;
border: 1px solid #86efac;
border-radius: 7px;
padding: 12px 14px;
background: #f0fdf4;
color: #166534;
}
.case-suggestions h4 {
margin: 0 0 7px;
color: #166534;
font-size: 12px;
}
.case-suggestions ol {
margin: 0;
padding-left: 17px;
}
.case-suggestions li {
margin: 4px 0;
font-size: 11px;
line-height: 1.45;
}
.solution-standard-row > div b {
color: var(--color-text-secondary);
font-weight: 500;
@ -796,6 +984,52 @@ function generateReport() {
font-size: 12px;
line-height: 1.7;
}
.solution-thinking {
margin-top: 12px;
border: 1px solid var(--color-border-light);
border-radius: 7px;
background: var(--color-bg-warm);
}
.solution-thinking__header {
display: flex;
width: 100%;
align-items: center;
gap: 7px;
border: 0;
padding: 8px 10px;
background: transparent;
color: var(--color-text-secondary);
font: inherit;
font-size: 11px;
text-align: left;
cursor: pointer;
}
.solution-thinking__title {
font-weight: 650;
}
.solution-thinking__status {
color: var(--color-success);
font-size: 10px;
}
.solution-thinking__arrow {
margin-left: auto;
color: var(--color-text-faint);
transition: transform 0.18s ease;
}
.solution-thinking__arrow.expanded {
transform: rotate(180deg);
}
.solution-thinking__steps {
margin: 0;
border-top: 1px solid var(--color-border-light);
padding: 7px 12px 8px 27px;
color: var(--color-text-muted);
font-size: 10px;
line-height: 1.6;
}
.solution-thinking__steps li {
margin: 2px 0;
}
.solution-standards {
margin-bottom: 10px;
border: 1px solid #bfdbfe;

View File

@ -9,10 +9,12 @@ 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 { directConversationQuery } from "@/api/conversations";
import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia";
import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation";
import {
readConversationReport,
writeConversationReport,
@ -46,6 +48,10 @@ const tool = ref<
const toolInput = ref("");
const toolError = ref("");
const toolFile = ref<File | null>(null);
const directResult = ref<Record<string, unknown> | null>(null);
const directLoading = ref(false);
const directUserPrompt = ref("");
const directResponseCard = ref<AssistantResponseCardData | null>(null);
const expandedReplies = ref(new Set<number>());
const primaryReportReady = ref(false);
const assessmentFields = ref({
@ -96,9 +102,15 @@ const toolPlaceholder = computed(() => {
return "请输入内容";
});
const toolSubmitLabel = computed(() =>
["assessment", "material", "cases", "solution"].includes(tool.value ?? "")
? "开始分析"
: "带入对话",
["supplier", "material", "cases"].includes(tool.value ?? "")
? directLoading.value
? "查询中…"
: "查询"
: tool.value === "solution"
? directLoading.value
? "生成中…"
: "生成方案"
: "提交",
);
const loadingText = computed(() =>
@ -186,7 +198,11 @@ function openTool(
return;
}
if (value === "supplier") {
router.push({ name: "supplier" });
tool.value = "supplier";
toolInput.value = "";
toolFile.value = null;
toolError.value = "";
directResult.value = null;
return;
}
if (value === "ocr") {
@ -197,6 +213,7 @@ function openTool(
toolInput.value = "";
toolFile.value = null;
toolError.value = "";
directResult.value = null;
assessmentFields.value = {
category: "",
specification: "",
@ -210,9 +227,32 @@ function openTool(
function closeTool() {
tool.value = null;
toolError.value = "";
directResult.value = null;
}
function submitTool() {
async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) {
directLoading.value = true;
toolError.value = "";
try {
const response = await directConversationQuery(kind, query);
directResult.value = response.data.data ?? null;
if (response.data.data) {
directUserPrompt.value = query;
directResponseCard.value = response.data.data as AssistantResponseCardData;
closeTool();
await nextTick();
await scrollToBottom();
}
} catch (reason) {
toolError.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "查询失败")
: reason instanceof Error ? reason.message : "查询失败";
} finally {
directLoading.value = false;
}
}
async function submitTool() {
const selectedTool = tool.value;
const selectedTitle = toolTitle.value;
if (selectedTool === "assessment") {
@ -237,31 +277,33 @@ function submitTool() {
toolError.value = "请输入要查询的物料名称";
return;
}
toolInput.value = `查物料风险:${value}`;
toolInput.value = value;
} else if (selectedTool === "cases") {
const value = toolInput.value.trim();
if (!value) {
toolError.value = "请输入要检索的异常现象或关键词";
return;
}
toolInput.value = `搜索相似案例:${value}`;
toolInput.value = value;
} else if (selectedTool === "solution") {
const value = toolInput.value.trim();
if (!value) {
toolError.value = "请输入需要生成方案的产品或风险问题";
return;
}
toolInput.value = `生成 AI 案例推理方案:${value}`;
toolInput.value = value;
}
const finalValue = toolInput.value.trim();
if (!finalValue && !toolFile.value) {
toolError.value = "请先填写必填内容";
return;
}
if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) {
await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue);
return;
}
closeTool();
void usePrompt(
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
);
void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`);
}
function selectFile(event: Event) {
@ -588,6 +630,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
:card="message.responseCard"
:can-append-report="primaryReportReady"
@generate-report="openSolutionReport"
@open-assessment="openTool('assessment')"
/>
<time class="message-time">{{
formatMessageTime(message.createdAt)
@ -596,6 +639,24 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</div>
</template>
<template v-if="directResponseCard">
<div class="message message--user">
<div class="message-avatar">{{ auth.user?.name?.slice(0, 1) ?? "我" }}</div>
<div class="user-bubble">{{ directUserPrompt }}</div>
</div>
<div class="message message--assistant">
<div class="message-avatar">蝶</div>
<div class="message-body">
<AssistantResponseCard
:card="directResponseCard"
:can-append-report="primaryReportReady"
@generate-report="openSolutionReport"
@open-assessment="openTool('assessment')"
/>
</div>
</div>
</template>
<div v-if="loading" class="message message--assistant">
<div class="message-avatar">蝶</div>
<div class="message-body">
@ -686,11 +747,49 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
/>
</label>
<p v-if="toolError" class="dialog-error">{{ toolError }}</p>
<section v-if="directResult" class="direct-result-card">
<template v-if="tool === 'supplier'">
<span class="direct-tier">{{ directResult.tier || "新供应商" }}</span>
<div class="direct-lines">
<p>近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b>(代理合作频次口径)</p>
<p>基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α <b>×{{ Number(directResult.frequencyCoefficient ?? 1).toFixed(2) }}</b></p>
<p>供应商得分 S供 = <b>{{ directResult.supplierScore ?? 70 }}</b> 分</p>
</div>
<p v-if="Array.isArray(directResult.topProblems) && directResult.topProblems.length">
高频异常:{{ (directResult.topProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }}
</p>
<p v-if="Array.isArray(directResult.suggestions)">建议:{{ (directResult.suggestions as string[]).join(" + ") }}</p>
</template>
<template v-else-if="tool === 'material'">
<strong>{{ directResult.title || "物料风险分析" }}</strong>
<p>物料:<b>{{ directResult.material || directResult.query }}</b> · 历史异常 {{ directResult.anomalyCount ?? 0 }} 条</p>
<p v-if="Array.isArray(directResult.frequentProblems) && directResult.frequentProblems.length">
高频问题:{{ (directResult.frequentProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }}
</p>
<p v-if="Array.isArray(directResult.suggestions)">建议:{{ (directResult.suggestions as string[]).join(";") }}</p>
</template>
<template v-else-if="tool === 'cases'">
<strong>相似案例检索</strong>
<p>关键词:{{ directResult.query }} · 匹配 {{ directResult.matchedCount ?? 0 }} 条</p>
<ul v-if="Array.isArray(directResult.cases)" class="direct-case-list">
<li v-for="(item, index) in (directResult.cases as Array<Record<string, unknown>>).slice(0, 5)" :key="index">
<b>{{ item.product || "未命名产品" }}</b>:{{ item.problem || item.description || "异常描述未填写" }}
</li>
</ul>
</template>
<template v-else>
<strong>{{ directResult.title || "AI 方案生成" }}</strong>
<p>{{ directResult.analysisBase || "已结合历史案例与标准库生成分析方案。" }}</p>
<ol v-if="Array.isArray(directResult.suggestions)" class="direct-suggestion-list">
<li v-for="item in directResult.suggestions as string[]" :key="item">{{ item }}</li>
</ol>
</template>
</section>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="closeTool">
取消
</button>
<button type="submit" class="dialog-primary">
<button type="submit" class="dialog-primary" :disabled="directLoading">
{{ toolSubmitLabel }}
</button>
</div>
@ -1070,6 +1169,52 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
color: white;
}
.direct-result-card {
margin-top: 14px;
border: 1px solid var(--color-border);
border-radius: 9px;
padding: 16px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.75;
}
.direct-result-card p {
margin: 3px 0;
}
.direct-result-card strong {
display: block;
margin-bottom: 8px;
color: var(--color-text);
font-size: 14px;
}
.direct-tier {
display: inline-block !important;
border-radius: 999px;
padding: 3px 10px;
background: var(--color-warning-soft);
color: #92400e !important;
font-size: 11px !important;
}
.direct-lines {
margin-top: 8px;
}
.direct-result-card ul,
.direct-result-card ol {
margin: 8px 0 0;
padding-left: 20px;
}
.direct-case-list li,
.direct-suggestion-list li {
margin: 3px 0;
}
@media (max-width: 760px) {
.conversation-column {
padding-inline: 12px;

View File

@ -1,7 +1,7 @@
import type { RiskAssessmentResult, RiskLevel } from "@/types/risk";
export interface AssistantResponseCard {
type: "supplier" | "material" | "standard" | "compliance" | "solution";
type: "supplier" | "material" | "standard" | "compliance" | "solution" | "cases";
title: string;
[key: string]: unknown;
}