| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421 |
- <template>
- <a-modal
- :visible="visible"
- :title="null"
- :footer="null"
- :width="620"
- :closable="false"
- :destroy-on-close="true"
- wrap-class-name="data-interpretation-modal"
- @cancel="$emit('close')"
- >
- <div class="di-header">
- <span class="di-header__title"><img :src="aiIcon" width="20" height="20" alt="" class="di-header__icon" />数据解读</span>
- <span class="di-header__close" @click="$emit('close')">✕</span>
- </div>
- <div ref="bodyRef" class="di-body">
- <div class="di-section">
- <div class="di-section__title">监测对象</div>
- <div class="di-section__content di-section__content--target">{{ props.itemName }}</div>
- </div>
- <div class="di-section">
- <div class="di-section__title">分析结论</div>
- <div class="di-section__content">
- <div v-if="showProcess" class="di-process">
- <div v-if="processCollapsed" class="di-process__summary" @click="processCollapsed = false">
- <span class="di-process__summary-icon">▸</span>
- <span>查看处理过程</span>
- </div>
- <template v-else>
- <div v-if="!props.loading" class="di-process__summary" @click="processCollapsed = true">
- <span class="di-process__summary-icon">▾</span>
- <span>收起处理过程</span>
- </div>
- <div v-if="thinking" class="di-process__row di-process__thinking"><a-spin size="small" /><span>AI 正在思考...</span></div>
- <div v-if="todos.length > 0" class="di-process__todos">
- <div v-for="(todo, i) in todos" :key="i" class="di-todo-item" :class="`di-todo-item--${todo.status}`">
- <span class="di-todo-item__icon">
- <span v-if="todo.status === 'completed'" class="di-todo-check">✓</span>
- <span v-else-if="todo.status === 'in_progress'" class="di-todo-spinner"></span>
- <span v-else class="di-todo-dot"></span>
- </span>
- <span class="di-todo-item__text">{{ todo.content }}</span>
- </div>
- </div>
- <div v-if="props.latestMessage && loading" class="di-process__row di-process__message">
- <span class="di-process__dot"></span><span>{{ props.latestMessage }}</span>
- </div>
- </template>
- </div>
- <div v-if="loading && !hasContent" class="di-loading"><a-spin tip="正在分析数据..." /></div>
- <div v-if="hasContent" class="di-markdown" v-html="renderedText"></div>
- <div v-else-if="error && !loading" class="di-error">数据解读失败:{{ error }}</div>
- </div>
- </div>
- </div>
- <div v-if="loading && hasContent" class="di-streaming-bar"><a-spin size="small" /><span class="di-streaming-bar__tip">AI 正在生成...</span></div>
- </a-modal>
- </template>
- <script lang="ts" setup>
- import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
- import { marked } from 'marked';
- import type { TodoItem, ToolCallRecord } from '../types';
- import aiIcon from '@/assets/images/ventAI/dataPicker/AI-icon.svg';
- const props = defineProps<{
- visible: boolean;
- loading: boolean;
- streamingText: string;
- error: string | null;
- thinking: boolean;
- todos: TodoItem[];
- executingTools: string[];
- toolCalls: ToolCallRecord[];
- latestMessage: string;
- itemName: string;
- }>();
- defineEmits<{ close: [] }>();
- const bodyRef = ref<HTMLDivElement>();
- const isNearBottom = ref(true);
- function checkNearBottom() {
- const el = bodyRef.value;
- if (!el) return;
- isNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
- }
- function scrollToBottom() {
- const el = bodyRef.value;
- if (!el) return;
- el.scrollTop = el.scrollHeight;
- }
- let scrollListenerAttached = false;
- onMounted(() => {
- const el = bodyRef.value;
- if (el) {
- el.addEventListener('scroll', checkNearBottom, { passive: true });
- scrollListenerAttached = true;
- }
- });
- onBeforeUnmount(() => {
- if (scrollListenerAttached && bodyRef.value) {
- bodyRef.value.removeEventListener('scroll', checkNearBottom);
- }
- });
- const hasContent = computed(() => !!props.streamingText);
- const hasProcess = computed(
- () => props.thinking || props.todos.length > 0 || props.executingTools.length > 0 || props.toolCalls.length > 0 || !!props.latestMessage
- );
- const showProcess = computed(() => hasProcess.value);
- const processCollapsed = ref(false);
- watch(
- () => props.loading,
- (val, oldVal) => {
- if (oldVal && !val && hasProcess.value) processCollapsed.value = true;
- }
- );
- watch(
- () => [props.streamingText, props.latestMessage, props.todos],
- () => {
- if (isNearBottom.value) {
- nextTick(scrollToBottom);
- }
- },
- { deep: true }
- );
- const renderedText = computed(() => {
- if (!props.streamingText) return '';
- const processed = props.streamingText.replace(/<br\s*\/?>/gi, ' \n');
- return marked.parse(processed);
- });
- </script>
- <style lang="less">
- .data-interpretation-modal {
- position: fixed !important;
- top: 0 !important;
- left: 0 !important;
- width: 100vw !important;
- height: 100vh !important;
- display: flex !important;
- align-items: flex-start !important;
- justify-content: center !important;
- padding-top: 10vh;
- z-index: 1000 !important;
- .ant-modal {
- top: 0;
- max-width: 90vw;
- }
- .ant-modal-content {
- color: var(--vent-font-color) !important;
- background-color: rgba(9, 23, 44, 0.3);
- border: 1px solid #3df6ff;
- box-shadow: inset 0 0 12px #2cb6ff;
- backdrop-filter: blur(30px);
- }
- .ant-modal-body {
- padding: 0;
- max-height: 75vh;
- overflow-y: auto;
- }
- .ant-spin-text {
- color: #c8d6e5;
- }
- }
- </style>
- <style lang="less" scoped>
- .di-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px;
- border-bottom: 1px solid rgba(30, 144, 255, 0.15);
- flex-shrink: 0;
- background-color: rgba(57, 163, 255, 0.2);
- &__title {
- font-size: 18px;
- font-weight: bold;
- color: #fff;
- letter-spacing: 2px;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- &__icon {
- display: inline-block;
- }
- &__close {
- cursor: pointer;
- font-size: 18px;
- color: #8899aa;
- &:hover {
- color: #fff;
- }
- }
- }
- .di-body {
- padding: 18px 24px;
- min-height: 120px;
- max-height: 700px;
- overflow-y: auto;
- }
- .di-section {
- margin-bottom: 16px;
- &:last-child {
- margin-bottom: 0;
- }
- &__title {
- color: #c9f0ff;
- font-size: 15px;
- font-weight: bold;
- margin-bottom: 8px;
- }
- &__content {
- border-top: 2px solid #2cb6ff;
- background: rgba(36, 138, 191, 0.1);
- padding: 12px;
- &--target {
- color: #fff;
- font-size: 14px;
- }
- }
- }
- .di-loading {
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 40px 0;
- }
- .di-process {
- margin-bottom: 16px;
- // border-bottom: 1px solid rgba(30, 144, 255, 0.12);
- &__row {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 0;
- font-size: 13px;
- color: #8899aa;
- }
- }
- .di-process__thinking {
- color: #4da6ff;
- font-size: 13px;
- }
- .di-process__executing {
- color: #c8a853;
- font-size: 13px;
- }
- .di-process__message {
- color: #8899aa;
- font-size: 13px;
- }
- .di-process__dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #4da6ff;
- animation: di-pulse 1.4s ease-in-out infinite;
- }
- @keyframes di-pulse {
- 0%,
- 100% {
- opacity: 0.3;
- }
- 50% {
- opacity: 1;
- }
- }
- .di-process__summary {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 0;
- font-size: 13px;
- color: #8899aa;
- cursor: pointer;
- user-select: none;
- &:hover {
- color: #4da6ff;
- }
- }
- .di-process__summary-icon {
- font-size: 12px;
- }
- .di-process__todos {
- margin: 6px 0;
- }
- .di-todo-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 0;
- font-size: 13px;
- line-height: 1.6;
- &__icon {
- flex-shrink: 0;
- width: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- &__text {
- color: #c8d6e5;
- }
- &--completed &__text {
- color: #6b7a8a;
- text-decoration: line-through;
- }
- &--in_progress &__text {
- color: #4da6ff;
- }
- }
- .di-todo-check {
- color: #27ae60;
- font-size: 12px;
- font-weight: bold;
- }
- .di-todo-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #4a5568;
- display: inline-block;
- }
- .di-todo-spinner {
- width: 12px;
- height: 12px;
- border: 2px solid rgba(77, 166, 255, 0.25);
- border-top-color: #4da6ff;
- border-radius: 50%;
- animation: di-spin 0.8s linear infinite;
- }
- @keyframes di-spin {
- to {
- transform: rotate(360deg);
- }
- }
- .di-markdown {
- line-height: 1.8;
- color: #fff;
- word-break: break-word;
- :deep(h1),
- :deep(h2),
- :deep(h3),
- :deep(h4) {
- color: #4da6ff;
- margin: 12px 0 8px;
- }
- :deep(p) {
- margin: 6px 0;
- }
- :deep(ul),
- :deep(ol) {
- padding-left: 20px;
- margin: 6px 0;
- }
- :deep(strong) {
- color: #fff;
- }
- :deep(code) {
- background: rgba(30, 144, 255, 0.15);
- padding: 1px 5px;
- border-radius: 3px;
- font-size: 13px;
- }
- :deep(pre) {
- background: rgba(0, 0, 0, 0.3);
- padding: 12px;
- border-radius: 6px;
- overflow-x: auto;
- }
- :deep(blockquote) {
- border-left: 3px solid #4da6ff;
- padding-left: 12px;
- margin: 8px 0;
- color: #8899aa;
- }
- :deep(table) {
- width: 100%;
- border-collapse: collapse;
- margin: 8px 0;
- th,
- td {
- border: 1px solid rgba(30, 144, 255, 0.2);
- padding: 6px 10px;
- text-align: left;
- }
- th {
- background: rgba(30, 144, 255, 0.1);
- color: #000;
- }
- }
- }
- .di-streaming-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 24px 16px;
- border-top: 1px solid rgba(30, 144, 255, 0.1);
- &__tip {
- font-size: 12px;
- color: #8899aa;
- }
- }
- .di-error {
- padding: 40px;
- text-align: center;
- color: #e74c3c;
- }
- </style>
|