|
|
@@ -1,878 +0,0 @@
|
|
|
-<template>
|
|
|
- <div ref="contentRef" class="markdown-view" :class="`markdown-view--${props.theme}`" @click="handleCitationClick">
|
|
|
- <template v-for="segment in renderedSegments" :key="segment.key">
|
|
|
- <div v-if="segment.type === 'markdown'" class="markdown-fragment" v-html="segment.html"></div>
|
|
|
- <section v-else class="markdown-score-radar" :data-score-block-key="segment.blockKey">
|
|
|
- <div class="markdown-score-radar__header">
|
|
|
- <div>
|
|
|
- <strong>{{ segment.data.studentName }} · AI评分雷达图</strong>
|
|
|
- <small>满分 {{ segment.data.maxScore }} 分</small>
|
|
|
- </div>
|
|
|
- <button class="markdown-score-radar__action" type="button" data-copy-exclude @click.stop="openExpertDialog(segment)">
|
|
|
- {{ hasExpertScores(segment) ? '修改专家评分' : '补充专家评分' }}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
-
|
|
|
- <svg class="markdown-score-radar__chart" viewBox="0 0 420 350" role="img" :aria-label="`${segment.data.studentName}评分雷达图`">
|
|
|
- <g transform="translate(210 164)">
|
|
|
- <polygon
|
|
|
- v-for="level in segment.data.maxScore"
|
|
|
- :key="`grid-${level}`"
|
|
|
- class="markdown-score-radar__grid"
|
|
|
- :points="gridPoints(segment.data.dimensions.length, level / segment.data.maxScore)"
|
|
|
- />
|
|
|
- <line
|
|
|
- v-for="(_, index) in segment.data.dimensions"
|
|
|
- :key="`axis-${index}`"
|
|
|
- class="markdown-score-radar__axis"
|
|
|
- x1="0"
|
|
|
- y1="0"
|
|
|
- :x2="polarPoint(index, segment.data.dimensions.length, 1).x"
|
|
|
- :y2="polarPoint(index, segment.data.dimensions.length, 1).y"
|
|
|
- />
|
|
|
- <polygon class="markdown-score-radar__area markdown-score-radar__area--ai" :points="scorePoints(segment, 'ai')" />
|
|
|
- <polygon v-if="hasExpertScores(segment)" class="markdown-score-radar__area markdown-score-radar__area--expert" :points="scorePoints(segment, 'expert')" />
|
|
|
- <template v-for="(dimension, index) in segment.data.dimensions" :key="`point-${dimension.key}`">
|
|
|
- <circle
|
|
|
- v-if="dimension.aiScore !== null"
|
|
|
- class="markdown-score-radar__point markdown-score-radar__point--ai"
|
|
|
- :cx="scorePoint(segment, index, 'ai').x"
|
|
|
- :cy="scorePoint(segment, index, 'ai').y"
|
|
|
- r="3.5"
|
|
|
- />
|
|
|
- <circle
|
|
|
- v-if="expertScore(segment, dimension.key) !== null"
|
|
|
- class="markdown-score-radar__point markdown-score-radar__point--expert"
|
|
|
- :cx="scorePoint(segment, index, 'expert').x"
|
|
|
- :cy="scorePoint(segment, index, 'expert').y"
|
|
|
- r="3.5"
|
|
|
- />
|
|
|
- <text
|
|
|
- class="markdown-score-radar__label"
|
|
|
- :x="labelPoint(index, segment.data.dimensions.length).x"
|
|
|
- :y="labelPoint(index, segment.data.dimensions.length).y"
|
|
|
- :text-anchor="labelAnchor(index, segment.data.dimensions.length)"
|
|
|
- dominant-baseline="middle"
|
|
|
- >{{ dimension.shortName }}</text>
|
|
|
- </template>
|
|
|
- </g>
|
|
|
- </svg>
|
|
|
-
|
|
|
- <div class="markdown-score-radar__legend">
|
|
|
- <span><i class="markdown-score-radar__legend-dot markdown-score-radar__legend-dot--ai"></i>AI评分</span>
|
|
|
- <span v-if="hasExpertScores(segment)"><i class="markdown-score-radar__legend-dot markdown-score-radar__legend-dot--expert"></i>专家评分</span>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="markdown-score-radar__score-list">
|
|
|
- <div v-for="dimension in segment.data.dimensions" :key="dimension.key">
|
|
|
- <span>{{ dimension.name }}</span>
|
|
|
- <b>AI:{{ formatScore(dimension.aiScore) }}</b>
|
|
|
- <b v-if="hasExpertScores(segment)">专家:{{ formatScore(expertScore(segment, dimension.key)) }}</b>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </section>
|
|
|
- </template>
|
|
|
- </div>
|
|
|
-
|
|
|
- <el-dialog v-model="expertDialogVisible" title="补充专家评分" width="min(520px, 92vw)" append-to-body destroy-on-close>
|
|
|
- <p class="markdown-score-dialog__hint">{{ editingSegment?.data.studentName }} · 评分范围1–5分,可留空表示未评分</p>
|
|
|
- <div class="markdown-score-dialog__list">
|
|
|
- <label v-for="dimension in editingSegment?.data.dimensions || []" :key="dimension.key">
|
|
|
- <span>{{ dimension.name }}</span>
|
|
|
- <el-select v-model="expertDraft[dimension.key]" clearable placeholder="未评分">
|
|
|
- <el-option v-for="score in 5" :key="score" :label="`${score}分`" :value="score" />
|
|
|
- </el-select>
|
|
|
- </label>
|
|
|
- </div>
|
|
|
- <template #footer>
|
|
|
- <el-button @click="expertDialogVisible = false">取消</el-button>
|
|
|
- <el-button type="primary" @click="saveExpertScores">确认并生成对比</el-button>
|
|
|
- </template>
|
|
|
- </el-dialog>
|
|
|
-</template>
|
|
|
-
|
|
|
-<script setup lang="ts">
|
|
|
-import MarkdownIt from 'markdown-it'
|
|
|
-import 'highlight.js/styles/vs2015.min.css'
|
|
|
-import hljs from 'highlight.js'
|
|
|
-import { computed, reactive, ref } from 'vue'
|
|
|
-
|
|
|
-const props = defineProps({
|
|
|
- content: {
|
|
|
- type: String,
|
|
|
- required: true
|
|
|
- },
|
|
|
- citationIds: {
|
|
|
- type: Array,
|
|
|
- default: () => []
|
|
|
- },
|
|
|
- theme: {
|
|
|
- type: String,
|
|
|
- default: 'light',
|
|
|
- validator: (value: string) => ['light', 'dark'].includes(value)
|
|
|
- },
|
|
|
- enableStudentScore: {
|
|
|
- type: Boolean,
|
|
|
- default: false
|
|
|
- },
|
|
|
- scoreOverrides: {
|
|
|
- type: Object,
|
|
|
- default: () => ({})
|
|
|
- }
|
|
|
-})
|
|
|
-
|
|
|
-const emit = defineEmits(['citation-click', 'student-score-change'])
|
|
|
-
|
|
|
-const contentRef = ref()
|
|
|
-const expertDialogVisible = ref(false)
|
|
|
-const editingSegment = ref<any>(null)
|
|
|
-const expertDraft = reactive<Record<string, number | null>>({})
|
|
|
-
|
|
|
-const SCORE_DIMENSION_KEYS = [
|
|
|
- 'customer_context',
|
|
|
- 'customer_insight',
|
|
|
- 'policy_interpretation',
|
|
|
- 'opportunity_risk',
|
|
|
- 'objection_reframing',
|
|
|
- 'fabetc'
|
|
|
-]
|
|
|
-
|
|
|
-type ScoreDimension = {
|
|
|
- key: string
|
|
|
- name: string
|
|
|
- shortName: string
|
|
|
- aiScore: number | null
|
|
|
-}
|
|
|
-
|
|
|
-type StudentScoreData = {
|
|
|
- studentId: string
|
|
|
- studentName: string
|
|
|
- maxScore: number
|
|
|
- dimensions: ScoreDimension[]
|
|
|
-}
|
|
|
-
|
|
|
-type MarkdownSegment = {
|
|
|
- key: string
|
|
|
- type: 'markdown'
|
|
|
- html: string
|
|
|
-}
|
|
|
-
|
|
|
-type RadarSegment = {
|
|
|
- key: string
|
|
|
- type: 'radar'
|
|
|
- blockKey: string
|
|
|
- data: StudentScoreData
|
|
|
-}
|
|
|
-
|
|
|
-type RenderedSegment = MarkdownSegment | RadarSegment
|
|
|
-
|
|
|
-/**
|
|
|
- * 将表格行按 | 分割,去除首尾空字符串,返回单元格数组
|
|
|
- */
|
|
|
-function parseTableRow(line: string): string[] {
|
|
|
- const parts = line.split('|')
|
|
|
- // 去除首尾空元素(因为 | 在开头和结尾)
|
|
|
- if (parts[0]?.trim() === '') parts.shift()
|
|
|
- if (parts[parts.length - 1]?.trim() === '') parts.pop()
|
|
|
- return parts.map(p => p.trim())
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * 检查字符串是否为有效的表格分隔符单元格
|
|
|
- * 标准格式:---, :---, ---:, :---:
|
|
|
- */
|
|
|
-function isSeparatorCell(cell: string): boolean {
|
|
|
- return /^:?-{2,}:?$/.test(cell)
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * 预处理 Markdown 内容,修复不规范的表格格式
|
|
|
- * 主要修复:分隔符行列数不匹配、格式错误、多余的竖线、空单元格格式问题
|
|
|
- */
|
|
|
-function normalizeMarkdownTables(content: string): string {
|
|
|
- if (!content) return content
|
|
|
-
|
|
|
- const lines = content.split('\n')
|
|
|
- const result: string[] = []
|
|
|
- let i = 0
|
|
|
-
|
|
|
- while (i < lines.length) {
|
|
|
- const line = lines[i]
|
|
|
- const trimmedLine = line.trim()
|
|
|
-
|
|
|
- // 检测可能的表格起始行(以 | 开头结尾,且包含多个单元格)
|
|
|
- if (trimmedLine.startsWith('|') && trimmedLine.endsWith('|') && trimmedLine.includes('|', 1) && i + 1 < lines.length) {
|
|
|
- const headerCells = parseTableRow(trimmedLine)
|
|
|
- const expectedCols = headerCells.length
|
|
|
-
|
|
|
- // 至少需要2列才能构成表格
|
|
|
- if (expectedCols >= 2) {
|
|
|
- const nextLineRaw = lines[i + 1]
|
|
|
- const nextLine = nextLineRaw?.trim() || ''
|
|
|
-
|
|
|
- // 检查下一行是否看起来像分隔符行(包含大量 | 和 -)
|
|
|
- const looksLikeSeparator = nextLine.startsWith('|') && nextLine.endsWith('|')
|
|
|
- && nextLine.includes('-') && /^\|[\s\-:|]+\|$/.test(nextLine)
|
|
|
-
|
|
|
- // 或尝试宽松检测:包含足够多的 | 和 - 字符
|
|
|
- const pipeCount = (nextLine.match(/\|/g) || []).length
|
|
|
- const dashCount = (nextLine.match(/-/g) || []).length
|
|
|
- const looseSeparatorMatch = pipeCount >= 2 && dashCount >= 3
|
|
|
-
|
|
|
- if (looksLikeSeparator || looseSeparatorMatch) {
|
|
|
- // 尝试解析分隔符行的单元格
|
|
|
- let sepCells = parseTableRow(nextLine)
|
|
|
-
|
|
|
- // 如果分隔符单元格解析后大部分是有效的分隔符格式,确认是表格
|
|
|
- const validSepCount = sepCells.filter(c => isSeparatorCell(c) || /^-+$/.test(c.replace(/\s/g, ''))).length
|
|
|
-
|
|
|
- if (validSepCount >= 1 || (pipeCount >= expectedCols - 1 && dashCount >= expectedCols * 2)) {
|
|
|
- // 确认是表格,开始修复
|
|
|
-
|
|
|
- // 修复分隔符行:确保列数与表头一致,每个单元格是 --- 格式
|
|
|
- const fixedSepCells: string[] = []
|
|
|
- for (let k = 0; k < expectedCols; k++) {
|
|
|
- const orig = sepCells[k] || ''
|
|
|
- // 保留原有的对齐标记(:)
|
|
|
- let leftAlign = orig.startsWith(':')
|
|
|
- let rightAlign = orig.endsWith(':')
|
|
|
- fixedSepCells.push((leftAlign ? ':' : '') + '---' + (rightAlign ? ':' : ''))
|
|
|
- }
|
|
|
- const fixedSepLine = '| ' + fixedSepCells.join(' | ') + ' |'
|
|
|
-
|
|
|
- const tableLines: string[] = []
|
|
|
- // 修复表头行,确保单元格之间格式正确
|
|
|
- tableLines.push('| ' + headerCells.join(' | ') + ' |')
|
|
|
- tableLines.push(fixedSepLine)
|
|
|
-
|
|
|
- let j = i + 2
|
|
|
- // 收集并修复数据行
|
|
|
- while (j < lines.length) {
|
|
|
- const dataLineRaw = lines[j]
|
|
|
- const dataLine = dataLineRaw?.trim() || ''
|
|
|
-
|
|
|
- if (dataLine.startsWith('|') && dataLine.endsWith('|')) {
|
|
|
- let dataCells = parseTableRow(dataLine)
|
|
|
-
|
|
|
- // 修复数据行列数:不足补空,多余截断
|
|
|
- if (dataCells.length < expectedCols) {
|
|
|
- while (dataCells.length < expectedCols) dataCells.push('')
|
|
|
- } else if (dataCells.length > expectedCols) {
|
|
|
- dataCells = dataCells.slice(0, expectedCols)
|
|
|
- }
|
|
|
-
|
|
|
- tableLines.push('| ' + dataCells.join(' | ') + ' |')
|
|
|
- j++
|
|
|
- } else if (dataLine === '') {
|
|
|
- // 空行可能表示表格结束,但也可能是表格内的换行(保守处理:表格结束)
|
|
|
- break
|
|
|
- } else {
|
|
|
- break
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- result.push(...tableLines)
|
|
|
- i = j
|
|
|
- continue
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- result.push(line)
|
|
|
- i++
|
|
|
- }
|
|
|
-
|
|
|
- return result.join('\n')
|
|
|
-}
|
|
|
-
|
|
|
-// ====== 创建 markdown-it 实例 ======
|
|
|
-const md = new MarkdownIt({
|
|
|
- html: true,
|
|
|
- linkify: true,
|
|
|
- typographer: true,
|
|
|
- breaks: false,
|
|
|
- highlight: function (str, lang) {
|
|
|
- if (lang && hljs.getLanguage(lang)) {
|
|
|
- try {
|
|
|
- const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
|
|
|
- return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>`
|
|
|
- } catch (__) {}
|
|
|
- }
|
|
|
- return `<pre><code>${escapeHtml(str)}</code></pre>`
|
|
|
- }
|
|
|
-})
|
|
|
-
|
|
|
-// ====== 关键修复:正确启用表格 ======
|
|
|
-md.enable('table')
|
|
|
-
|
|
|
-md.inline.ruler.before('text', 'citation_mark', (state, silent) => {
|
|
|
- const match = /^\[S(\d+)]/.exec(state.src.slice(state.pos, state.posMax))
|
|
|
- const citationIds = Array.isArray(state.env.citationIds) ? state.env.citationIds : []
|
|
|
- if (!match || !citationIds.includes(Number(match[1]))) return false
|
|
|
- if (silent) return true
|
|
|
-
|
|
|
- const token = state.push('citation_mark', '', 0)
|
|
|
- token.meta = { id: Number(match[1]) }
|
|
|
- token.content = match[0]
|
|
|
- state.pos += match[0].length
|
|
|
- return true
|
|
|
-})
|
|
|
-
|
|
|
-md.renderer.rules.citation_mark = (tokens, idx) => {
|
|
|
- const id = tokens[idx].meta.id
|
|
|
- return `<button class="citation-mark" type="button" data-citation-id="${id}">${tokens[idx].content}</button>`
|
|
|
-}
|
|
|
-
|
|
|
-function handleCitationClick(event: MouseEvent) {
|
|
|
- const target = event.target as HTMLElement | null
|
|
|
- const mark = target?.closest<HTMLElement>('[data-citation-id]')
|
|
|
- const segmentId = Number(mark?.dataset.citationId)
|
|
|
- if (Number.isFinite(segmentId)) emit('citation-click', segmentId)
|
|
|
-}
|
|
|
-
|
|
|
-function escapeHtml(text: string): string {
|
|
|
- const map: Record<string, string> = {
|
|
|
- '&': '&',
|
|
|
- '<': '<',
|
|
|
- '>': '>',
|
|
|
- '"': '"',
|
|
|
- "'": '''
|
|
|
- }
|
|
|
- return text.replace(/[&<>"']/g, function(m) { return map[m] })
|
|
|
-}
|
|
|
-
|
|
|
-// ====== 覆盖表格渲染规则 ======
|
|
|
-const defaultTableOpen = md.renderer.rules.table_open
|
|
|
-const defaultTableClose = md.renderer.rules.table_close
|
|
|
-
|
|
|
-md.renderer.rules.table_open = function(tokens, idx, options, env, self) {
|
|
|
- return '<div class="markdown-table-wrap"><table>'
|
|
|
-}
|
|
|
-
|
|
|
-md.renderer.rules.table_close = function(tokens, idx, options, env, self) {
|
|
|
- return '</table></div>'
|
|
|
-}
|
|
|
-
|
|
|
-// ====== 确保 tbody 正确渲染 ======
|
|
|
-md.renderer.rules.tbody_open = function(tokens, idx, options, env, self) {
|
|
|
- return '<tbody>'
|
|
|
-}
|
|
|
-
|
|
|
-md.renderer.rules.tbody_close = function(tokens, idx, options, env, self) {
|
|
|
- return '</tbody>'
|
|
|
-}
|
|
|
-
|
|
|
-function normalizeStudentScoreData(value: unknown): StudentScoreData | null {
|
|
|
- if (!value || typeof value !== 'object') return null
|
|
|
- const raw = value as Record<string, any>
|
|
|
- const maxScore = Number(raw.maxScore)
|
|
|
- if (!raw.studentName || !Number.isInteger(maxScore) || maxScore !== 5 || !Array.isArray(raw.dimensions)) return null
|
|
|
- if (raw.dimensions.length !== SCORE_DIMENSION_KEYS.length) return null
|
|
|
-
|
|
|
- const dimensions: ScoreDimension[] = []
|
|
|
- for (let index = 0; index < SCORE_DIMENSION_KEYS.length; index++) {
|
|
|
- const item = raw.dimensions[index]
|
|
|
- if (!item || item.key !== SCORE_DIMENSION_KEYS[index] || !item.name || !item.shortName) return null
|
|
|
- const aiScore = item.aiScore === null ? null : Number(item.aiScore)
|
|
|
- if (aiScore !== null && (!Number.isInteger(aiScore) || aiScore < 1 || aiScore > maxScore)) return null
|
|
|
- dimensions.push({
|
|
|
- key: String(item.key),
|
|
|
- name: String(item.name),
|
|
|
- shortName: String(item.shortName),
|
|
|
- aiScore
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- studentId: String(raw.studentId || ''),
|
|
|
- studentName: String(raw.studentName),
|
|
|
- maxScore,
|
|
|
- dimensions
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function renderMarkdown(markdown: string): string {
|
|
|
- if (!markdown) return ''
|
|
|
- const normalizedContent = normalizeMarkdownTables(markdown)
|
|
|
- return md.render(normalizedContent, { citationIds: props.citationIds })
|
|
|
-}
|
|
|
-
|
|
|
-const renderedSegments = computed<any[]>(() => {
|
|
|
- if (!props.content) return []
|
|
|
- if (!props.enableStudentScore) {
|
|
|
- return [{ key: 'markdown-0', type: 'markdown', html: renderMarkdown(props.content) }]
|
|
|
- }
|
|
|
-
|
|
|
- const segments: RenderedSegment[] = []
|
|
|
- const pattern = /```student-score-json\s*\r?\n([\s\S]*?)\r?\n```/g
|
|
|
- let cursor = 0
|
|
|
- let blockIndex = 0
|
|
|
- let match: RegExpExecArray | null
|
|
|
-
|
|
|
- try {
|
|
|
- while ((match = pattern.exec(props.content)) !== null) {
|
|
|
- const before = props.content.slice(cursor, match.index)
|
|
|
- if (before) segments.push({ key: `markdown-${blockIndex}`, type: 'markdown', html: renderMarkdown(before) })
|
|
|
-
|
|
|
- let data: StudentScoreData | null = null
|
|
|
- try {
|
|
|
- data = normalizeStudentScoreData(JSON.parse(match[1]))
|
|
|
- } catch (_) {
|
|
|
- data = null
|
|
|
- }
|
|
|
-
|
|
|
- if (data) {
|
|
|
- const identity = data.studentId || data.studentName || `student-${blockIndex + 1}`
|
|
|
- const blockKey = `${identity}-${blockIndex}`
|
|
|
- segments.push({ key: `radar-${blockKey}`, type: 'radar', blockKey, data })
|
|
|
- } else {
|
|
|
- segments.push({ key: `markdown-invalid-${blockIndex}`, type: 'markdown', html: renderMarkdown(match[0]) })
|
|
|
- }
|
|
|
-
|
|
|
- cursor = pattern.lastIndex
|
|
|
- blockIndex += 1
|
|
|
- }
|
|
|
-
|
|
|
- const rest = props.content.slice(cursor)
|
|
|
- if (rest) segments.push({ key: `markdown-${blockIndex}`, type: 'markdown', html: renderMarkdown(rest) })
|
|
|
- return segments
|
|
|
- } catch (error) {
|
|
|
- console.error('Markdown 渲染失败:', error)
|
|
|
- return [{ key: 'markdown-error', type: 'markdown', html: escapeHtml(props.content) }]
|
|
|
- }
|
|
|
-})
|
|
|
-
|
|
|
-const RADAR_RADIUS = 104
|
|
|
-const LABEL_RADIUS = 132
|
|
|
-
|
|
|
-function polarPoint(index: number, count: number, ratio: number, radius = RADAR_RADIUS) {
|
|
|
- const angle = -Math.PI / 2 + (Math.PI * 2 * index) / count
|
|
|
- return {
|
|
|
- x: Number((Math.cos(angle) * radius * ratio).toFixed(2)),
|
|
|
- y: Number((Math.sin(angle) * radius * ratio).toFixed(2))
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function gridPoints(count: number, ratio: number) {
|
|
|
- return Array.from({ length: count }, (_, index) => {
|
|
|
- const point = polarPoint(index, count, ratio)
|
|
|
- return `${point.x},${point.y}`
|
|
|
- }).join(' ')
|
|
|
-}
|
|
|
-
|
|
|
-function getExpertScoreMap(segment: RadarSegment): Record<string, number | null> {
|
|
|
- const value = props.scoreOverrides?.[segment.blockKey]
|
|
|
- return value && typeof value === 'object' ? value : {}
|
|
|
-}
|
|
|
-
|
|
|
-function expertScore(segment: RadarSegment, key: string): number | null {
|
|
|
- const value = getExpertScoreMap(segment)[key]
|
|
|
- return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= segment.data.maxScore ? Number(value) : null
|
|
|
-}
|
|
|
-
|
|
|
-function hasExpertScores(segment: RadarSegment) {
|
|
|
- return segment.data.dimensions.some((dimension) => expertScore(segment, dimension.key) !== null)
|
|
|
-}
|
|
|
-
|
|
|
-function scorePoint(segment: RadarSegment, index: number, source: 'ai' | 'expert') {
|
|
|
- const dimension = segment.data.dimensions[index]
|
|
|
- const score = source === 'ai' ? dimension.aiScore : expertScore(segment, dimension.key)
|
|
|
- return polarPoint(index, segment.data.dimensions.length, (score ?? 0) / segment.data.maxScore)
|
|
|
-}
|
|
|
-
|
|
|
-function scorePoints(segment: RadarSegment, source: 'ai' | 'expert') {
|
|
|
- return segment.data.dimensions.map((_, index) => {
|
|
|
- const point = scorePoint(segment, index, source)
|
|
|
- return `${point.x},${point.y}`
|
|
|
- }).join(' ')
|
|
|
-}
|
|
|
-
|
|
|
-function labelPoint(index: number, count: number) {
|
|
|
- return polarPoint(index, count, 1, LABEL_RADIUS)
|
|
|
-}
|
|
|
-
|
|
|
-function labelAnchor(index: number, count: number) {
|
|
|
- const x = labelPoint(index, count).x
|
|
|
- if (Math.abs(x) < 8) return 'middle'
|
|
|
- return x > 0 ? 'start' : 'end'
|
|
|
-}
|
|
|
-
|
|
|
-function formatScore(score: number | null) {
|
|
|
- return score === null ? '未评分' : `${score}分`
|
|
|
-}
|
|
|
-
|
|
|
-function openExpertDialog(segment: RadarSegment) {
|
|
|
- editingSegment.value = segment
|
|
|
- Object.keys(expertDraft).forEach((key) => delete expertDraft[key])
|
|
|
- segment.data.dimensions.forEach((dimension) => {
|
|
|
- expertDraft[dimension.key] = expertScore(segment, dimension.key)
|
|
|
- })
|
|
|
- expertDialogVisible.value = true
|
|
|
-}
|
|
|
-
|
|
|
-function saveExpertScores() {
|
|
|
- const segment = editingSegment.value as RadarSegment | null
|
|
|
- if (!segment) return
|
|
|
- const scores: Record<string, number | null> = {}
|
|
|
- segment.data.dimensions.forEach((dimension) => {
|
|
|
- const value = expertDraft[dimension.key]
|
|
|
- scores[dimension.key] = Number.isInteger(value) ? Number(value) : null
|
|
|
- })
|
|
|
- emit('student-score-change', {
|
|
|
- blockKey: segment.blockKey,
|
|
|
- studentId: segment.data.studentId,
|
|
|
- studentName: segment.data.studentName,
|
|
|
- scores
|
|
|
- })
|
|
|
- expertDialogVisible.value = false
|
|
|
-}
|
|
|
-</script>
|
|
|
-
|
|
|
-<style lang="scss">
|
|
|
-.markdown-view {
|
|
|
- --md-text: #000;
|
|
|
- --md-heading: #000;
|
|
|
- --md-table-wrap-bg: rgba(15, 23, 42, .02);
|
|
|
- --md-table-border: rgba(15, 23, 42, .18);
|
|
|
- --md-table-head-bg: #edf3fa;
|
|
|
- --md-table-head-text: #000;
|
|
|
- --md-table-cell-text: #000;
|
|
|
- --md-table-row-even: rgba(15, 23, 42, .035);
|
|
|
- --md-table-row-hover: rgba(74, 137, 231, .1);
|
|
|
- --md-scroll-track: rgba(15, 23, 42, .05);
|
|
|
- --md-scroll-thumb: rgba(74, 137, 231, .35);
|
|
|
- --md-scroll-thumb-hover: rgba(74, 137, 231, .55);
|
|
|
- font-family: 'SourceHanSansCN-Normal';
|
|
|
- font-weight: 400;
|
|
|
- letter-spacing: 0em;
|
|
|
- text-align: left;
|
|
|
- color: var(--md-text);
|
|
|
- max-width: 100%;
|
|
|
-
|
|
|
- &.markdown-view--dark {
|
|
|
- --md-text: #f0f5ff;
|
|
|
- --md-heading: #f0f5ff;
|
|
|
- --md-table-wrap-bg: rgba(255, 255, 255, .02);
|
|
|
- --md-table-border: rgba(174, 205, 255, .25);
|
|
|
- --md-table-head-bg: rgba(74, 137, 231, .4);
|
|
|
- --md-table-head-text: #fff;
|
|
|
- --md-table-cell-text: #e8f0ff;
|
|
|
- --md-table-row-even: rgba(255, 255, 255, .03);
|
|
|
- --md-table-row-hover: rgba(74, 137, 231, .1);
|
|
|
- --md-scroll-track: rgba(255, 255, 255, .04);
|
|
|
- --md-scroll-thumb: rgba(74, 137, 231, .35);
|
|
|
- --md-scroll-thumb-hover: rgba(74, 137, 231, .55);
|
|
|
- }
|
|
|
-
|
|
|
- pre {
|
|
|
- position: relative;
|
|
|
- }
|
|
|
-
|
|
|
- pre code.hljs {
|
|
|
- width: auto;
|
|
|
- }
|
|
|
-
|
|
|
- code.hljs {
|
|
|
- border-radius: 6px;
|
|
|
- padding-top: 20px;
|
|
|
- width: auto;
|
|
|
- @media screen and (min-width: 1536px) {
|
|
|
- width: 960px;
|
|
|
- }
|
|
|
- @media screen and (max-width: 1536px) and (min-width: 1024px) {
|
|
|
- width: calc(100vw - 400px - 64px - 32px * 2);
|
|
|
- }
|
|
|
- @media screen and (max-width: 1024px) and (min-width: 768px) {
|
|
|
- width: calc(100vw - 32px * 2);
|
|
|
- }
|
|
|
- @media screen and (max-width: 768px) {
|
|
|
- width: calc(100vw - 16px * 2);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- p, code.hljs {
|
|
|
- margin-bottom: 16px;
|
|
|
- }
|
|
|
-
|
|
|
- p {
|
|
|
- margin: 0;
|
|
|
- margin-bottom: 3px;
|
|
|
- }
|
|
|
-
|
|
|
- h1, h2, h3, h4, h5, h6 {
|
|
|
- color: var(--md-heading);
|
|
|
- margin: 24px 0 8px;
|
|
|
- font-weight: 600;
|
|
|
- }
|
|
|
-
|
|
|
- h1 { font-size: 22px; line-height: 32px; }
|
|
|
- h2 { font-size: 20px; line-height: 30px; }
|
|
|
- h3 { font-size: 18px; line-height: 28px; }
|
|
|
- h4 { font-size: 16px; line-height: 26px; }
|
|
|
- h5 { font-size: 16px; line-height: 24px; }
|
|
|
- h6 { font-size: 16px; line-height: 24px; }
|
|
|
-
|
|
|
- ul, ol {
|
|
|
- margin: 0 0 8px 0;
|
|
|
- padding: 0;
|
|
|
- font-size: 16px;
|
|
|
- line-height: 24px;
|
|
|
- color: var(--md-text);
|
|
|
- }
|
|
|
-
|
|
|
- li {
|
|
|
- margin: 4px 0 0 20px;
|
|
|
- margin-bottom: 1rem;
|
|
|
- }
|
|
|
-
|
|
|
- ol > li {
|
|
|
- list-style-type: decimal;
|
|
|
- margin-bottom: 1rem;
|
|
|
- }
|
|
|
-
|
|
|
- ul > li {
|
|
|
- list-style-type: disc;
|
|
|
- font-size: 16px;
|
|
|
- line-height: 24px;
|
|
|
- margin-right: 11px;
|
|
|
- margin-bottom: 1rem;
|
|
|
- color: var(--md-text);
|
|
|
- }
|
|
|
-
|
|
|
- ol ul, ol ul > li, ul ul, ul ul li {
|
|
|
- font-size: 16px;
|
|
|
- list-style: none;
|
|
|
- margin-left: 6px;
|
|
|
- margin-bottom: 1rem;
|
|
|
- }
|
|
|
-
|
|
|
- ul ul ul, ul ul ul li, ol ol, ol ol > li, ol ul ul, ol ul ul > li, ul ol, ul ol > li {
|
|
|
- list-style: square;
|
|
|
- }
|
|
|
-
|
|
|
- // 表格容器样式
|
|
|
- .markdown-table-wrap {
|
|
|
- display: block !important;
|
|
|
- overflow-x: auto;
|
|
|
- margin: 16px 0;
|
|
|
- border-radius: 8px;
|
|
|
- border: 1px solid var(--md-table-border) !important;
|
|
|
- -webkit-overflow-scrolling: touch;
|
|
|
- background: var(--md-table-wrap-bg);
|
|
|
-
|
|
|
- &::-webkit-scrollbar {
|
|
|
- height: 6px;
|
|
|
- }
|
|
|
- &::-webkit-scrollbar-track {
|
|
|
- background: var(--md-scroll-track);
|
|
|
- border-radius: 3px;
|
|
|
- }
|
|
|
- &::-webkit-scrollbar-thumb {
|
|
|
- background: var(--md-scroll-thumb);
|
|
|
- border-radius: 3px;
|
|
|
- }
|
|
|
- &::-webkit-scrollbar-thumb:hover {
|
|
|
- background: var(--md-scroll-thumb-hover);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 强制表格元素使用正确的display属性,防止被其他样式覆盖
|
|
|
- table {
|
|
|
- display: table !important;
|
|
|
- width: 100% !important;
|
|
|
- min-width: 480px !important;
|
|
|
- border-collapse: collapse !important;
|
|
|
- border-spacing: 0 !important;
|
|
|
- margin: 0 !important;
|
|
|
- font-size: 13px;
|
|
|
- table-layout: auto;
|
|
|
- }
|
|
|
-
|
|
|
- thead {
|
|
|
- display: table-header-group !important;
|
|
|
- }
|
|
|
-
|
|
|
- tbody {
|
|
|
- display: table-row-group !important;
|
|
|
- }
|
|
|
-
|
|
|
- tr {
|
|
|
- display: table-row !important;
|
|
|
- page-break-inside: avoid;
|
|
|
- }
|
|
|
-
|
|
|
- th {
|
|
|
- display: table-cell !important;
|
|
|
- background: var(--md-table-head-bg) !important;
|
|
|
- color: var(--md-table-head-text) !important;
|
|
|
- font-weight: 600 !important;
|
|
|
- padding: 10px 14px !important;
|
|
|
- border: 1px solid var(--md-table-border) !important;
|
|
|
- text-align: left !important;
|
|
|
- white-space: nowrap;
|
|
|
- position: relative;
|
|
|
- }
|
|
|
-
|
|
|
- td {
|
|
|
- display: table-cell !important;
|
|
|
- color: var(--md-table-cell-text) !important;
|
|
|
- padding: 10px 14px !important;
|
|
|
- border: 1px solid var(--md-table-border) !important;
|
|
|
- vertical-align: top !important;
|
|
|
- text-align: left !important;
|
|
|
- word-break: break-word;
|
|
|
- line-height: 1.6;
|
|
|
- }
|
|
|
-
|
|
|
- // 表格斑马纹效果
|
|
|
- tr:nth-child(even) td {
|
|
|
- background: var(--md-table-row-even) !important;
|
|
|
- }
|
|
|
-
|
|
|
- tr:nth-child(odd) td {
|
|
|
- background: transparent !important;
|
|
|
- }
|
|
|
-
|
|
|
- // 表格hover效果
|
|
|
- tr:hover td {
|
|
|
- background: var(--md-table-row-hover) !important;
|
|
|
- }
|
|
|
-
|
|
|
- // 表格内段落样式修正
|
|
|
- table p, td p, th p {
|
|
|
- margin: 0 !important;
|
|
|
- padding: 0 !important;
|
|
|
- }
|
|
|
-
|
|
|
- // 防止表格内容被转义成纯文本
|
|
|
- td, th {
|
|
|
- white-space: normal;
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar {
|
|
|
- margin: 16px 0 8px;
|
|
|
- overflow: hidden;
|
|
|
- border: 1px solid var(--md-table-border);
|
|
|
- border-radius: 12px;
|
|
|
- background: var(--md-table-wrap-bg);
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__header {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: space-between;
|
|
|
- gap: 12px;
|
|
|
- padding: 14px 16px;
|
|
|
- border-bottom: 1px solid var(--md-table-border);
|
|
|
-
|
|
|
- strong, small { display: block; }
|
|
|
- strong { color: var(--md-heading); font-size: 15px; }
|
|
|
- small { margin-top: 2px; color: var(--md-text); opacity: .62; font-size: 11px; }
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__action {
|
|
|
- flex: 0 0 auto;
|
|
|
- padding: 7px 12px;
|
|
|
- border: 1px solid rgba(91, 152, 237, .7);
|
|
|
- border-radius: 8px;
|
|
|
- color: #fff;
|
|
|
- background: linear-gradient(135deg, #5b98ed, #4a72ca);
|
|
|
- font: inherit;
|
|
|
- font-size: 12px;
|
|
|
- cursor: pointer;
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__chart {
|
|
|
- display: block;
|
|
|
- width: min(100%, 560px);
|
|
|
- height: auto;
|
|
|
- margin: 4px auto 0;
|
|
|
- overflow: visible;
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__grid,
|
|
|
- .markdown-score-radar__axis {
|
|
|
- fill: none;
|
|
|
- stroke: var(--md-table-border);
|
|
|
- stroke-width: 1;
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__area {
|
|
|
- stroke-width: 2.2;
|
|
|
- stroke-linejoin: round;
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__area--ai { fill: rgba(62, 130, 219, .24); stroke: #4f9cff; }
|
|
|
- .markdown-score-radar__area--expert { fill: rgba(245, 158, 11, .18); stroke: #f59e0b; }
|
|
|
- .markdown-score-radar__point--ai { fill: #4f9cff; }
|
|
|
- .markdown-score-radar__point--expert { fill: #f59e0b; }
|
|
|
- .markdown-score-radar__label { fill: var(--md-text); font-size: 11px; }
|
|
|
-
|
|
|
- .markdown-score-radar__legend {
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- gap: 20px;
|
|
|
- margin: -12px 0 12px;
|
|
|
- color: var(--md-text);
|
|
|
- font-size: 12px;
|
|
|
-
|
|
|
- span { display: inline-flex; align-items: center; gap: 6px; }
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__legend-dot {
|
|
|
- width: 9px;
|
|
|
- height: 9px;
|
|
|
- border-radius: 50%;
|
|
|
- }
|
|
|
-
|
|
|
- .markdown-score-radar__legend-dot--ai { background: #4f9cff; }
|
|
|
- .markdown-score-radar__legend-dot--expert { background: #f59e0b; }
|
|
|
-
|
|
|
- .markdown-score-radar__score-list {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
- border-top: 1px solid var(--md-table-border);
|
|
|
-
|
|
|
- > div {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: minmax(0, 1fr) auto auto;
|
|
|
- gap: 8px;
|
|
|
- padding: 8px 12px;
|
|
|
- border-right: 1px solid var(--md-table-border);
|
|
|
- border-bottom: 1px solid var(--md-table-border);
|
|
|
- color: var(--md-text);
|
|
|
- font-size: 11px;
|
|
|
- }
|
|
|
-
|
|
|
- span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
- b { font-weight: 500; white-space: nowrap; }
|
|
|
- }
|
|
|
-
|
|
|
- @media (max-width: 620px) {
|
|
|
- .markdown-score-radar__header { align-items: flex-start; flex-direction: column; }
|
|
|
- .markdown-score-radar__score-list { grid-template-columns: 1fr; }
|
|
|
- .markdown-score-radar__label { font-size: 9px; }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.markdown-score-dialog__hint {
|
|
|
- margin: 0 0 12px;
|
|
|
- color: #64748b;
|
|
|
- font-size: 13px;
|
|
|
-}
|
|
|
-
|
|
|
-.markdown-score-dialog__list {
|
|
|
- display: grid;
|
|
|
- gap: 10px;
|
|
|
-
|
|
|
- label {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: minmax(0, 1fr) 120px;
|
|
|
- align-items: center;
|
|
|
- gap: 12px;
|
|
|
- }
|
|
|
-
|
|
|
- span { font-size: 13px; }
|
|
|
-}
|
|
|
-</style>
|