|
@@ -2,23 +2,36 @@
|
|
|
<div class="ai-summary">
|
|
<div class="ai-summary">
|
|
|
<div class="ai-header">
|
|
<div class="ai-header">
|
|
|
<h3 class="ai-title">{{ lang.ssSpkAISummary }}</h3>
|
|
<h3 class="ai-title">{{ lang.ssSpkAISummary }}</h3>
|
|
|
- <button class="ai-refresh-btn" :disabled="loading" @click="$emit('refresh')">
|
|
|
|
|
|
|
+ <button class="ai-refresh-btn" :disabled="streaming" @click="$emit('refresh')">
|
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
- :class="{ spinning: loading }">
|
|
|
|
|
|
|
+ :class="{ spinning: streaming }">
|
|
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
|
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
|
|
</svg>
|
|
</svg>
|
|
|
- {{ lang.ssSpkRefresh }}
|
|
|
|
|
|
|
+ {{ streaming ? lang.ssSpkReportStreaming : lang.ssSpkRegenerate }}
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
- <ul class="ai-bullets">
|
|
|
|
|
- <li>• {{ bullets[0] }}</li>
|
|
|
|
|
- <li v-if="loading && !bullets[1]" class="ai-skeleton"><span /></li>
|
|
|
|
|
- <li v-else>• {{ bullets[1] }}</li>
|
|
|
|
|
- <li v-if="loading && !bullets[2]" class="ai-skeleton"><span /></li>
|
|
|
|
|
- <li v-else>• {{ bullets[2] }}</li>
|
|
|
|
|
- </ul>
|
|
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 折叠态常驻行 -->
|
|
|
|
|
+ <div class="ai-completion">{{ completionLine }}</div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 展开/收起 -->
|
|
|
|
|
+ <button class="ai-toggle" @click="expanded = !expanded">
|
|
|
|
|
+ {{ expanded ? lang.ssSpkCollapseReport : lang.ssSpkViewFullReport }}
|
|
|
|
|
+ <span class="ai-caret" :class="{ open: expanded }">▾</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="expanded" class="ai-report-region">
|
|
|
|
|
+ <div v-if="error" class="ai-report-error">
|
|
|
|
|
+ <span>{{ lang.ssSpkReportFailed }}</span>
|
|
|
|
|
+ <button @click="$emit('refresh')">{{ lang.ssSpkRetry }}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else-if="streaming && !markdown" class="ai-skeleton"><span /></div>
|
|
|
|
|
+ <!-- 渲染结果已由 renderReportMarkdown 经 DOMPurify 消毒 -->
|
|
|
|
|
+ <div v-else class="ai-report-body" v-html="renderedHtml"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div v-if="generatedAtLabel" class="ai-meta">{{ generatedAtLabel }}</div>
|
|
<div v-if="generatedAtLabel" class="ai-meta">{{ generatedAtLabel }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
@@ -26,14 +39,21 @@
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
|
import { lang } from '@/main'
|
|
import { lang } from '@/main'
|
|
|
|
|
+import { renderReportMarkdown } from './renderReport'
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
|
- bullets: [string, string, string]
|
|
|
|
|
- loading: boolean
|
|
|
|
|
|
|
+ completionLine: string
|
|
|
|
|
+ markdown: string
|
|
|
|
|
+ streaming: boolean
|
|
|
generatedAt: string | null
|
|
generatedAt: string | null
|
|
|
|
|
+ error: string | null
|
|
|
}>()
|
|
}>()
|
|
|
defineEmits<{ refresh: [] }>()
|
|
defineEmits<{ refresh: [] }>()
|
|
|
|
|
|
|
|
|
|
+const expanded = ref(false)
|
|
|
|
|
+
|
|
|
|
|
+const renderedHtml = computed(() => renderReportMarkdown(props.markdown))
|
|
|
|
|
+
|
|
|
// 「刚刚生成」/「N 秒前生成」/「N 分钟前生成」 — 每秒重算
|
|
// 「刚刚生成」/「N 秒前生成」/「N 分钟前生成」 — 每秒重算
|
|
|
const now = ref(Date.now())
|
|
const now = ref(Date.now())
|
|
|
let timer: number | null = null
|
|
let timer: number | null = null
|
|
@@ -90,10 +110,82 @@ const generatedAtLabel = computed(() => {
|
|
|
}
|
|
}
|
|
|
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
|
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
|
|
|
|
|
|
|
-.ai-bullets {
|
|
|
|
|
- list-style: none; padding: 0; margin: 0;
|
|
|
|
|
- display: flex; flex-direction: column; gap: 4px;
|
|
|
|
|
- font-size: 12px; color: #374151;
|
|
|
|
|
|
|
+.ai-completion {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.ai-toggle {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ color: #2563eb;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.ai-caret {
|
|
|
|
|
+ transition: transform .15s;
|
|
|
|
|
+ &.open { transform: rotate(180deg); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.ai-report-region {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ max-height: 360px;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ border-top: 1px solid #e5e7eb;
|
|
|
|
|
+ padding-top: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.ai-report-error {
|
|
|
|
|
+ background: #fef2f2;
|
|
|
|
|
+ color: #b91c1c;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+
|
|
|
|
|
+ button {
|
|
|
|
|
+ padding: 2px 8px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #fecaca;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ color: #b91c1c;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.ai-report-body {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+
|
|
|
|
|
+ :deep(table) {
|
|
|
|
|
+ border-collapse: collapse;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ margin: 8px 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(th), :deep(td) {
|
|
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
|
|
+ padding: 4px 8px;
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(th) { background: #f3f4f6; font-weight: 600; }
|
|
|
|
|
+ :deep(pre) {
|
|
|
|
|
+ background: #f3f4f6;
|
|
|
|
|
+ padding: 8px;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ overflow-x: auto;
|
|
|
|
|
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(h3) { font-size: 13px; margin: 12px 0 6px; }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.ai-skeleton span {
|
|
.ai-skeleton span {
|