Compare commits
3 Commits
5582f168c2
...
92a86efe73
| Author | SHA1 | Date | |
|---|---|---|---|
| 92a86efe73 | |||
| b5484c8adb | |||
| 6402aaf83e |
@ -29,6 +29,10 @@ export function fetchConversation(id: number, offset = 0) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteConversation(id: number) {
|
||||||
|
return http.delete<ApiResponse<null>>(`/v1/conversations/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function sendConversationMessage(
|
export function sendConversationMessage(
|
||||||
id: number,
|
id: number,
|
||||||
content: string,
|
content: string,
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
|
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
import { featureFlags } from "@/config/features";
|
import { featureFlags } from "@/config/features";
|
||||||
|
|
||||||
defineProps<{ modelValue: string; loading: boolean; canUpload: boolean }>();
|
defineProps<{ modelValue: string; loading: boolean; canUpload: boolean }>();
|
||||||
@ -31,6 +33,13 @@ const emit = defineEmits<{
|
|||||||
| "voice",
|
| "voice",
|
||||||
];
|
];
|
||||||
}>();
|
}>();
|
||||||
|
const textarea = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
textarea.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus });
|
||||||
|
|
||||||
const functions: Array<{
|
const functions: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
@ -115,6 +124,7 @@ const functions: Array<{
|
|||||||
<Camera :size="16" />
|
<Camera :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="textarea"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="输入产品参数或问题…"
|
placeholder="输入产品参数或问题…"
|
||||||
@ -145,7 +155,11 @@ const functions: Array<{
|
|||||||
:key="item.title"
|
:key="item.title"
|
||||||
class="function-card"
|
class="function-card"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('tool', item.tool)"
|
@click="
|
||||||
|
item.tool === 'assessment' || !item.prompt
|
||||||
|
? emit('tool', item.tool)
|
||||||
|
: emit('select', item.prompt)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<span class="function-dot" :class="`function-dot--${item.tone}`"></span>
|
<span class="function-dot" :class="`function-dot--${item.tone}`"></span>
|
||||||
<component :is="item.icon" :size="13" />
|
<component :is="item.icon" :size="13" />
|
||||||
|
|||||||
@ -258,7 +258,6 @@ function generateReport() {
|
|||||||
<b>{{ standard.riskPoint }}</b
|
<b>{{ standard.riskPoint }}</b
|
||||||
><small>{{ standard.rule }} · {{ standard.source }}</small>
|
><small>{{ standard.rule }} · {{ standard.source }}</small>
|
||||||
</div>
|
</div>
|
||||||
<strong>{{ standard.status }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="empty">暂无直接匹配规则</p>
|
<p v-else class="empty">暂无直接匹配规则</p>
|
||||||
@ -317,12 +316,6 @@ function generateReport() {
|
|||||||
{{ standard.source || "标准文件.xlsx" }}</small
|
{{ standard.source || "标准文件.xlsx" }}</small
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<strong
|
|
||||||
:class="{
|
|
||||||
ok: standard.status === '合规' || standard.status === '打样必测',
|
|
||||||
}"
|
|
||||||
>{{ standard.status }}</strong
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<p v-if="!data.standards?.length" class="empty">
|
<p v-if="!data.standards?.length" class="empty">
|
||||||
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
|
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
|
||||||
@ -696,11 +689,6 @@ function generateReport() {
|
|||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.standard-row > strong {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--color-warning);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.solution-standard-row {
|
.solution-standard-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -753,14 +741,6 @@ function generateReport() {
|
|||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.solution-standard-row > strong {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--color-warning);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.solution-standard-row > strong.ok {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
.suggestion-list {
|
.suggestion-list {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
|||||||
@ -13,7 +13,10 @@ import { computed } from "vue";
|
|||||||
|
|
||||||
import { featureFlags } from "@/config/features";
|
import { featureFlags } from "@/config/features";
|
||||||
|
|
||||||
const emit = defineEmits<{ select: [prompt: string] }>();
|
const emit = defineEmits<{
|
||||||
|
select: [prompt: string];
|
||||||
|
tool: [value: "assessment" | "ocr"];
|
||||||
|
}>();
|
||||||
const props = defineProps<{ canUpload: boolean }>();
|
const props = defineProps<{ canUpload: boolean }>();
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
@ -23,6 +26,7 @@ const actions = [
|
|||||||
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
|
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
|
||||||
icon: Search,
|
icon: Search,
|
||||||
tone: "blue",
|
tone: "blue",
|
||||||
|
tool: "assessment" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "查供应商",
|
title: "查供应商",
|
||||||
@ -69,6 +73,7 @@ const actions = [
|
|||||||
icon: Camera,
|
icon: Camera,
|
||||||
tone: "sky",
|
tone: "sky",
|
||||||
requiresUpload: true,
|
requiresUpload: true,
|
||||||
|
tool: "ocr" as const,
|
||||||
},
|
},
|
||||||
...(featureFlags.voiceEntry
|
...(featureFlags.voiceEntry
|
||||||
? [
|
? [
|
||||||
@ -90,6 +95,14 @@ const visibleActions = computed(() =>
|
|||||||
props.canUpload,
|
props.canUpload,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleAction(action: (typeof actions)[number]) {
|
||||||
|
if ("tool" in action && action.tool) {
|
||||||
|
emit("tool", action.tool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("select", action.prompt);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -107,7 +120,7 @@ const visibleActions = computed(() =>
|
|||||||
:key="action.title"
|
:key="action.title"
|
||||||
class="welcome-action"
|
class="welcome-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('select', action.prompt)"
|
@click="handleAction(action)"
|
||||||
>
|
>
|
||||||
<span class="welcome-action__icon" :class="`tone-${action.tone}`">
|
<span class="welcome-action__icon" :class="`tone-${action.tone}`">
|
||||||
<component :is="action.icon" :size="16" />
|
<component :is="action.icon" :size="16" />
|
||||||
|
|||||||
@ -108,13 +108,6 @@ const standardCategories = computed(() => [
|
|||||||
(props.result.standardJudgment ?? []).map((item) => item.category),
|
(props.result.standardJudgment ?? []).map((item) => item.category),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const pendingStandardCount = computed(
|
|
||||||
() =>
|
|
||||||
(props.result.standardJudgment ?? []).filter(
|
|
||||||
(item) => !["合规", "合格", "达标"].includes(item.status),
|
|
||||||
).length,
|
|
||||||
);
|
|
||||||
|
|
||||||
function openStandards(category = "all", keyword?: string) {
|
function openStandards(category = "all", keyword?: string) {
|
||||||
void router.push({
|
void router.push({
|
||||||
name: "standards",
|
name: "standards",
|
||||||
@ -287,18 +280,12 @@ function openSupplier() {
|
|||||||
{{ standard.source || "标准文件.xlsx" }}</small
|
{{ standard.source || "标准文件.xlsx" }}</small
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
|
|
||||||
standard.status
|
|
||||||
}}</strong>
|
|
||||||
</button>
|
</button>
|
||||||
<footer>
|
<footer>
|
||||||
<span class="standards-card__engine-icon">↻</span>
|
<span class="standards-card__engine-icon">↻</span>
|
||||||
<span
|
<span
|
||||||
><b>双引擎融合:</b
|
><b>双引擎融合:</b
|
||||||
>历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。<template
|
>历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。</span
|
||||||
v-if="pendingStandardCount"
|
|
||||||
>{{ pendingStandardCount }} 项待检标准已加入检测任务清单</template
|
|
||||||
></span
|
|
||||||
>
|
>
|
||||||
<span class="standards-card__links">
|
<span class="standards-card__links">
|
||||||
<button
|
<button
|
||||||
@ -895,16 +882,6 @@ h3 {
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standard-row > strong {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--color-warning);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standard-row > .status-action {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.standards-card__empty,
|
.standards-card__empty,
|
||||||
.case-empty {
|
.case-empty {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
|
|||||||
@ -14,18 +14,18 @@ const { user } = storeToRefs(auth);
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"];
|
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "风险项管理"];
|
||||||
const navigationRoutes: Record<string, string> = {
|
const navigationRoutes: Record<string, string> = {
|
||||||
对话: "assessment",
|
对话: "assessment",
|
||||||
看板: "overview",
|
看板: "overview",
|
||||||
供应商: "supplier",
|
供应商: "supplier",
|
||||||
标准库: "standards",
|
标准库: "standards",
|
||||||
报告: "report",
|
报告: "report",
|
||||||
管理: "review",
|
风险项管理: "review",
|
||||||
};
|
};
|
||||||
const visibleNavigation = computed(() =>
|
const visibleNavigation = computed(() =>
|
||||||
navigation.filter(
|
navigation.filter(
|
||||||
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
|
(item) => item !== "风险项管理" || auth.hasPermission("risk_item.review"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ const routeChunks: Record<string, () => Promise<unknown>> = {
|
|||||||
供应商: () => import("@/pages/SupplierProfilePage.vue"),
|
供应商: () => import("@/pages/SupplierProfilePage.vue"),
|
||||||
标准库: () => import("@/pages/StandardsPage.vue"),
|
标准库: () => import("@/pages/StandardsPage.vue"),
|
||||||
报告: () => import("@/pages/ReportPage.vue"),
|
报告: () => import("@/pages/ReportPage.vue"),
|
||||||
管理: () => import("@/pages/ReviewPage.vue"),
|
风险项管理: () => import("@/pages/ReviewPage.vue"),
|
||||||
};
|
};
|
||||||
const prefetched = new Set<string>();
|
const prefetched = new Set<string>();
|
||||||
function prefetch(item: string) {
|
function prefetch(item: string) {
|
||||||
@ -67,7 +67,7 @@ function prefetch(item: string) {
|
|||||||
(item === '供应商' && route.name === 'supplier') ||
|
(item === '供应商' && route.name === 'supplier') ||
|
||||||
(item === '标准库' && route.name === 'standards') ||
|
(item === '标准库' && route.name === 'standards') ||
|
||||||
(item === '报告' && route.name === 'report') ||
|
(item === '报告' && route.name === 'report') ||
|
||||||
(item === '管理' && route.name === 'review') ||
|
(item === '风险项管理' && route.name === 'review') ||
|
||||||
(item === '对话' && route.name === 'assessment'),
|
(item === '对话' && route.name === 'assessment'),
|
||||||
}"
|
}"
|
||||||
@mouseenter="prefetch(item)"
|
@mouseenter="prefetch(item)"
|
||||||
|
|||||||
@ -95,6 +95,18 @@ async function openConversation(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeConversation(item: { id: number; title: string }) {
|
||||||
|
if (!window.confirm(`确定删除会话“${item.title}”吗?删除后无法恢复。`)) return;
|
||||||
|
try {
|
||||||
|
await conversationStore.remove(item.id);
|
||||||
|
if (conversationStore.current === null) {
|
||||||
|
await router.push({ name: "assessment", query: { new: String(Date.now()) } });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The store exposes the request error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function searchConversations() {
|
function searchConversations() {
|
||||||
void conversationStore.loadList(search.value.trim() || undefined);
|
void conversationStore.loadList(search.value.trim() || undefined);
|
||||||
}
|
}
|
||||||
@ -131,14 +143,15 @@ function searchConversations() {
|
|||||||
<p v-else-if="!conversations.length" class="sidebar-state">
|
<p v-else-if="!conversations.length" class="sidebar-state">
|
||||||
暂无历史对话
|
暂无历史对话
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div
|
||||||
v-for="item in conversations"
|
v-for="item in conversations"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
:class="{
|
:class="{
|
||||||
'sidebar-item--active': conversationStore.current?.id === item.id,
|
'sidebar-item--active': conversationStore.current?.id === item.id,
|
||||||
}"
|
}"
|
||||||
type="button"
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
@click="openConversation(item.id)"
|
@click="openConversation(item.id)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -148,7 +161,16 @@ function searchConversations() {
|
|||||||
></span>
|
></span>
|
||||||
<span v-else class="risk-dot risk-dot--empty"></span>
|
<span v-else class="risk-dot risk-dot--empty"></span>
|
||||||
<span class="sidebar-item__label">{{ item.title }}</span>
|
<span class="sidebar-item__label">{{ item.title }}</span>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sidebar-item__delete"
|
||||||
|
title="删除会话"
|
||||||
|
aria-label="删除会话"
|
||||||
|
@click.stop="removeConversation(item)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@ -264,6 +286,32 @@ section h2 {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-item__delete {
|
||||||
|
display: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover .sidebar-item__delete,
|
||||||
|
.sidebar-item:focus-within .sidebar-item__delete {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item__delete:hover {
|
||||||
|
background: var(--color-danger-subtle);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.risk-dot {
|
.risk-dot {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
|
|||||||
@ -220,11 +220,7 @@ function dimensionConclusion(item: {
|
|||||||
蝶雅产品部 · 风险预警系统 · 自动生成 · {{ generatedAt }}
|
蝶雅产品部 · 风险预警系统 · 自动生成 · {{ generatedAt }}
|
||||||
</p>
|
</p>
|
||||||
<div class="report-meta">
|
<div class="report-meta">
|
||||||
<span><b>产品/评估对象:</b>{{ report.subjectId }}</span>
|
<span
|
||||||
><span
|
|
||||||
><b>供应商:</b
|
|
||||||
>{{ report.supplierFreq?.supplierName || "未指定" }}</span
|
|
||||||
><span
|
|
||||||
><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{
|
><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{
|
||||||
report.score.toFixed(0)
|
report.score.toFixed(0)
|
||||||
}}</span
|
}}</span
|
||||||
|
|||||||
@ -264,8 +264,8 @@ onMounted(() => {
|
|||||||
<div class="admin-page">
|
<div class="admin-page">
|
||||||
<header class="admin-header">
|
<header class="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">ADMIN CONSOLE</p>
|
<p class="eyebrow">RISK ITEM CONSOLE</p>
|
||||||
<h1>管理工作台</h1>
|
<h1>风险项管理工作台</h1>
|
||||||
<p class="admin-description">风险项录入、审核与数据质量管理</p>
|
<p class="admin-description">风险项录入、审核与数据质量管理</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-header-actions">
|
<div class="admin-header-actions">
|
||||||
@ -692,8 +692,8 @@ onMounted(() => {
|
|||||||
><ClipboardCheck :size="16"
|
><ClipboardCheck :size="16"
|
||||||
/></span>
|
/></span>
|
||||||
<div>
|
<div>
|
||||||
<h3>风险项录入</h3>
|
<h3>风险项表单录入</h3>
|
||||||
<p>表单录入并提交审核</p>
|
<p>填写风险项并提交审核</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<button
|
<button
|
||||||
@ -701,7 +701,7 @@ onMounted(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
@click="router.push({ name: 'risk-item-entry' })"
|
@click="router.push({ name: 'risk-item-entry' })"
|
||||||
>
|
>
|
||||||
<Plus :size="14" />打开录入表单
|
<Plus :size="14" />新建风险项
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article
|
||||||
@ -713,20 +713,20 @@ onMounted(() => {
|
|||||||
><ImagePlus :size="16"
|
><ImagePlus :size="16"
|
||||||
/></span>
|
/></span>
|
||||||
<div>
|
<div>
|
||||||
<h3>OCR 识别录入</h3>
|
<h3>风险项 OCR 录入</h3>
|
||||||
<p>上传检测报告、巡检单或异常报告</p>
|
<p>识别报告并生成待审核风险项</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<button class="tool-link" type="button" @click="ocrOpen = true">
|
<button class="tool-link" type="button" @click="ocrOpen = true">
|
||||||
<Upload :size="14" />开始 OCR 识别
|
<Upload :size="14" />识别并录入风险项
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
<article v-if="featureFlags.voiceEntry" class="tool-card">
|
<article v-if="featureFlags.voiceEntry" class="tool-card">
|
||||||
<header>
|
<header>
|
||||||
<span class="tool-icon tool-icon--purple"><Mic :size="16" /></span>
|
<span class="tool-icon tool-icon--purple"><Mic :size="16" /></span>
|
||||||
<div>
|
<div>
|
||||||
<h3>语音录入</h3>
|
<h3>风险项语音录入</h3>
|
||||||
<p>口述异常信息,自动提取字段</p>
|
<p>口述异常并生成待审核风险项</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<button class="voice-entry" type="button">
|
<button class="voice-entry" type="button">
|
||||||
@ -742,12 +742,12 @@ onMounted(() => {
|
|||||||
><FileSpreadsheet :size="16"
|
><FileSpreadsheet :size="16"
|
||||||
/></span>
|
/></span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Excel 批量导入</h3>
|
<h3>风险项 Excel 批量导入</h3>
|
||||||
<p>按模板批量进入审核队列</p>
|
<p>按模板批量生成待审核风险项</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<button class="tool-link" type="button">
|
<button class="tool-link" type="button">
|
||||||
<Upload :size="14" />上传 Excel 文件</button
|
<Upload :size="14" />批量导入风险项</button
|
||||||
><small class="tool-hint">单次最多 1000 条,自动去重校验</small>
|
><small class="tool-hint">单次最多 1000 条,自动去重校验</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="tool-card config-card">
|
<article class="tool-card config-card">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onActivated, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
|
import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
|
||||||
@ -31,6 +31,7 @@ const withdrawingId = ref<number | null>(null);
|
|||||||
const prompt = ref("");
|
const prompt = ref("");
|
||||||
const ocrOpen = ref(false);
|
const ocrOpen = ref(false);
|
||||||
const scrollArea = ref<HTMLElement | null>(null);
|
const scrollArea = ref<HTMLElement | null>(null);
|
||||||
|
const composer = ref<InstanceType<typeof AssistantComposer> | null>(null);
|
||||||
const tool = ref<
|
const tool = ref<
|
||||||
| "assessment"
|
| "assessment"
|
||||||
| "supplier"
|
| "supplier"
|
||||||
@ -44,6 +45,7 @@ const tool = ref<
|
|||||||
const toolInput = ref("");
|
const toolInput = ref("");
|
||||||
const toolError = ref("");
|
const toolError = ref("");
|
||||||
const toolFile = ref<File | null>(null);
|
const toolFile = ref<File | null>(null);
|
||||||
|
const expandedReplies = ref(new Set<number>());
|
||||||
const assessmentFields = ref({
|
const assessmentFields = ref({
|
||||||
category: "",
|
category: "",
|
||||||
specification: "",
|
specification: "",
|
||||||
@ -109,6 +111,12 @@ onMounted(async () => {
|
|||||||
await openFromRoute();
|
await openFromRoute();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onActivated(async () => {
|
||||||
|
// MainLayout keeps this page alive. Returning from report/dashboard therefore
|
||||||
|
// does not remount it, so explicitly reveal the newest message on re-entry.
|
||||||
|
if (conversation.value?.messages.length) await scrollToBottom("auto");
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.conversation,
|
() => route.query.conversation,
|
||||||
async () => {
|
async () => {
|
||||||
@ -116,9 +124,45 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function usePrompt(value: string) {
|
async function usePrompt(value: string) {
|
||||||
prompt.value = value;
|
const current = prompt.value.trim();
|
||||||
void submit();
|
prompt.value = current ? `${current}\n${value}` : value;
|
||||||
|
await nextTick();
|
||||||
|
composer.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReply(messageId: number) {
|
||||||
|
const next = new Set(expandedReplies.value);
|
||||||
|
if (next.has(messageId)) next.delete(messageId);
|
||||||
|
else next.add(messageId);
|
||||||
|
expandedReplies.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCollapseReply(content: string) {
|
||||||
|
return content.trim().length > 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageThinking(messageId: number, index: number) {
|
||||||
|
const messages = conversation.value?.messages ?? [];
|
||||||
|
return (
|
||||||
|
generating.value &&
|
||||||
|
index === messages.length - 1 &&
|
||||||
|
messages[index]?.id === messageId &&
|
||||||
|
messages[index]?.role === "assistant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function thinkingDuration(index: number) {
|
||||||
|
const messages = conversation.value?.messages ?? [];
|
||||||
|
const message = messages[index];
|
||||||
|
const previous = messages[index - 1];
|
||||||
|
if (!message || !previous || previous.role !== "user") return null;
|
||||||
|
const elapsed = Math.round(
|
||||||
|
(new Date(message.createdAt).getTime() -
|
||||||
|
new Date(previous.createdAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
return elapsed > 0 ? elapsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTool(
|
function openTool(
|
||||||
@ -210,14 +254,9 @@ function submitTool() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeTool();
|
closeTool();
|
||||||
prompt.value =
|
void usePrompt(
|
||||||
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`;
|
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
|
||||||
const messageType = toolFile.value
|
);
|
||||||
? selectedTool === "ocr"
|
|
||||||
? "image"
|
|
||||||
: "audio"
|
|
||||||
: "text";
|
|
||||||
void submit(messageType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(event: Event) {
|
function selectFile(event: Event) {
|
||||||
@ -277,7 +316,11 @@ async function openFromRoute() {
|
|||||||
const rawId = route.query.conversation;
|
const rawId = route.query.conversation;
|
||||||
if (typeof rawId !== "string") return;
|
if (typeof rawId !== "string") return;
|
||||||
const id = Number(rawId);
|
const id = Number(rawId);
|
||||||
if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id !== id) {
|
if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id === id) {
|
||||||
|
await scrollToBottom("auto");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isSafeInteger(id) && id > 0) {
|
||||||
try {
|
try {
|
||||||
await conversationStore.open(id);
|
await conversationStore.open(id);
|
||||||
await scrollToBottom("auto");
|
await scrollToBottom("auto");
|
||||||
@ -434,10 +477,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
v-if="!conversation?.messages.length"
|
v-if="!conversation?.messages.length"
|
||||||
:can-upload="auth.hasPermission('risk_item.submit')"
|
:can-upload="auth.hasPermission('risk_item.submit')"
|
||||||
@select="usePrompt"
|
@select="usePrompt"
|
||||||
|
@tool="openTool"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-for="message in conversation?.messages ?? []"
|
v-for="(message, messageIndex) in conversation?.messages ?? []"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
>
|
>
|
||||||
<div class="message" :class="`message--${message.role}`">
|
<div class="message" :class="`message--${message.role}`">
|
||||||
@ -488,13 +532,70 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
message.content &&
|
message.content &&
|
||||||
!message.riskResult &&
|
(message.riskResult ||
|
||||||
!message.responseCard
|
message.responseCard ||
|
||||||
|
isMessageThinking(message.id, messageIndex))
|
||||||
"
|
"
|
||||||
|
class="assistant-thinking"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="assistant-thinking__header"
|
||||||
|
:disabled="
|
||||||
|
isMessageThinking(message.id, messageIndex) ||
|
||||||
|
!canCollapseReply(message.content)
|
||||||
|
"
|
||||||
|
@click="toggleReply(message.id)"
|
||||||
|
>
|
||||||
|
<span v-if="isMessageThinking(message.id, messageIndex)"
|
||||||
|
>思考中…</span
|
||||||
|
>
|
||||||
|
<span v-else>
|
||||||
|
已完成<span v-if="thinkingDuration(messageIndex)">
|
||||||
|
{{ thinkingDuration(messageIndex) }}s</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="canCollapseReply(message.content)"
|
||||||
|
class="assistant-thinking__arrow"
|
||||||
|
>{{ expandedReplies.has(message.id) ? "⌄" : "›" }}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="assistant-bubble"
|
||||||
|
:class="{
|
||||||
|
'assistant-bubble--collapsed':
|
||||||
|
canCollapseReply(message.content) &&
|
||||||
|
!expandedReplies.has(message.id),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ message.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="message.content"
|
||||||
class="assistant-bubble"
|
class="assistant-bubble"
|
||||||
|
:class="{
|
||||||
|
'assistant-bubble--collapsed':
|
||||||
|
canCollapseReply(message.content) &&
|
||||||
|
!expandedReplies.has(message.id),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="
|
||||||
|
message.content &&
|
||||||
|
!message.riskResult &&
|
||||||
|
!message.responseCard &&
|
||||||
|
canCollapseReply(message.content)
|
||||||
|
"
|
||||||
|
type="button"
|
||||||
|
class="reply-toggle"
|
||||||
|
@click="toggleReply(message.id)"
|
||||||
|
>
|
||||||
|
{{ expandedReplies.has(message.id) ? "收起" : "展开全文" }}
|
||||||
|
</button>
|
||||||
<RiskAnalysisCard
|
<RiskAnalysisCard
|
||||||
v-if="message.riskResult"
|
v-if="message.riskResult"
|
||||||
:result="message.riskResult"
|
:result="message.riskResult"
|
||||||
@ -533,6 +634,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssistantComposer
|
<AssistantComposer
|
||||||
|
ref="composer"
|
||||||
v-model="prompt"
|
v-model="prompt"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:can-upload="auth.hasPermission('risk_item.submit')"
|
:can-upload="auth.hasPermission('risk_item.submit')"
|
||||||
@ -740,6 +842,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-bubble {
|
.assistant-bubble {
|
||||||
|
position: relative;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 82%;
|
max-width: 82%;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@ -751,6 +854,54 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-bubble--collapsed {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-height: calc(2 * 1.65em + 22px);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-thinking {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-thinking__header {
|
||||||
|
display: flex;
|
||||||
|
width: auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 0;
|
||||||
|
margin: 0 0 5px 2px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-thinking__header:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-thinking__arrow {
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-toggle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.semantic-strip {
|
.semantic-strip {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createConversation,
|
createConversation,
|
||||||
|
deleteConversation,
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
fetchConversations,
|
fetchConversations,
|
||||||
withdrawConversationMessage,
|
withdrawConversationMessage,
|
||||||
@ -132,6 +133,16 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
return current.value;
|
return current.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
const active = current.value?.id === id;
|
||||||
|
await deleteConversation(id);
|
||||||
|
streamStates.get(id)?.controller.abort();
|
||||||
|
streamStates.delete(id);
|
||||||
|
conversationCache.delete(id);
|
||||||
|
items.value = items.value.filter((item) => item.id !== id);
|
||||||
|
if (active) current.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function send(
|
async function send(
|
||||||
content: string,
|
content: string,
|
||||||
messageType: ConversationMessageType = "text",
|
messageType: ConversationMessageType = "text",
|
||||||
@ -428,6 +439,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
open,
|
open,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
create,
|
create,
|
||||||
|
remove,
|
||||||
send,
|
send,
|
||||||
streamSend,
|
streamSend,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user