Files
suanfa/src/components/CodeViewer.vue
T
2026-06-16 09:35:51 +08:00

451 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="code-viewer" ref="viewerRef" :class="{ fullscreen: isFullscreen }">
<div class="code-toolbar">
<div class="file-info">
<span class="file-icon">📄</span>
<span class="file-name">{{ fileName }}</span>
<span v-if="fileLabel" class="file-label">{{ fileLabel }}</span>
</div>
<div class="toolbar-actions">
<span v-if="description" class="file-desc-tip" :title="description"></span>
<!-- 字号控制 -->
<div class="font-size-control">
<button class="tool-btn" @click="fontSizeDown" title="缩小字体">A</button>
<span class="font-size-value">{{ fontSize }}px</span>
<button class="tool-btn" @click="fontSizeUp" title="放大字体">A+</button>
<button class="tool-btn reset-btn" @click="fontSizeReset" title="重置字号"></button>
</div>
<!-- 全屏 -->
<button class="tool-btn" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏'">
{{ isFullscreen ? '⛶' : '⛶' }}
</button>
<!-- 运行 -->
<button
class="tool-btn run-btn"
@click="runCode"
:disabled="running"
:title="running ? '正在运行...' : `运行${language === 'python' ? ' Python' : ' C'}代码`"
>
{{ running ? '⏳' : '▶' }} 运行
</button>
<!-- 复制 -->
<button class="tool-btn" @click="copyCode" :title="copied ? '已复制!' : '复制代码'">
{{ copied ? '✅' : '📋' }}
</button>
</div>
</div>
<div class="code-content" ref="codeRef" @wheel="onWheel">
<pre :style="{ fontSize: fontSize + 'px', lineHeight: lineHeight + 'px' }"><code v-html="highlightedCode"></code></pre>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">加载中...</div>
</div>
<div v-if="error" class="error-overlay">
<p> {{ error }}</p>
</div>
</div>
<!-- 运行输出面板 -->
<div v-if="runOutput !== null" class="run-output" :class="{ collapsed: outputCollapsed }">
<div class="output-header" @click="outputCollapsed = !outputCollapsed">
<span class="output-title">
{{ runOutput.isError ? '❌' : '✅' }} 运行结果
</span>
<span class="output-toggle">{{ outputCollapsed ? '展开' : '折叠' }}</span>
<button class="tool-btn close-btn" @click.stop="runOutput = null" title="关闭"></button>
</div>
<div class="output-body">
<pre :class="{ 'error-text': runOutput.isError }">{{ runOutput.text }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { loadCodeFile, highlightC, highlightPython } from '../utils/codeUtils.js'
const props = defineProps({
filePath: String,
fileName: String,
fileLabel: String,
description: String,
language: { type: String, default: 'c' } // 'c' | 'python'
})
const codeContent = ref('')
const loading = ref(false)
const error = ref('')
const copied = ref(false)
const fontSize = ref(13)
const isFullscreen = ref(false)
const viewerRef = ref(null)
const codeRef = ref(null)
const MIN_FONT = 10
const MAX_FONT = 36
const FONT_STEP = 2
const highlightedCode = computed(() => {
if (!codeContent.value) return ''
if (props.language === 'python') return highlightPython(codeContent.value)
return highlightC(codeContent.value)
})
const lineHeight = computed(() => Math.max(22, fontSize.value * 1.5))
async function loadCode() {
if (!props.filePath) return
loading.value = true
error.value = ''
codeContent.value = ''
try {
codeContent.value = await loadCodeFile(props.filePath)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
watch(() => props.filePath, loadCode, { immediate: true })
// 字号控制
function fontSizeUp() {
fontSize.value = Math.min(fontSize.value + FONT_STEP, MAX_FONT)
}
function fontSizeDown() {
fontSize.value = Math.max(fontSize.value - FONT_STEP, MIN_FONT)
}
function fontSizeReset() {
fontSize.value = 13
}
// Ctrl+滚轮缩放
function onWheel(e) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
if (e.deltaY < 0) fontSizeUp()
else fontSizeDown()
}
}
// 全屏
async function toggleFullscreen() {
if (!viewerRef.value) return
if (isFullscreen.value) {
try {
await document.exitFullscreen()
} catch {}
isFullscreen.value = false
} else {
try {
await viewerRef.value.requestFullscreen()
isFullscreen.value = true
} catch {}
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
// 监听全屏变化
if (typeof document !== 'undefined') {
document.addEventListener('fullscreenchange', onFullscreenChange)
}
const running = ref(false)
const runOutput = ref(null) // { text: string, isError: boolean }
const outputCollapsed = ref(false)
async function runCode() {
if (!codeContent.value) return
running.value = true
runOutput.value = null
outputCollapsed.value = false
const apiEndpoint = props.language === 'python' ? '/api/run-py' : '/api/run-c'
try {
const res = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: codeContent.value })
})
const data = await res.json()
runOutput.value = {
text: data.error || data.hint
? `⚠️ ${data.error || '未知错误'}\n${data.hint || ''}\n${data.installGuide || ''}`
: data.output,
isError: data.isError || !!data.error
}
} catch (e) {
runOutput.value = {
text: `❌ 无法连接到运行服务\n请确认后端服务已启动 (npm run server)`,
isError: true
}
} finally {
running.value = false
}
}
async function copyCode() {
try {
await navigator.clipboard.writeText(codeContent.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
const textarea = document.createElement('textarea')
textarea.value = codeContent.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
}
</script>
<style scoped>
.code-viewer {
background: var(--code-bg);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
margin-bottom: 20px;
}
.code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--code-header-bg);
border-bottom: 1px solid var(--border-color);
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.file-icon {
font-size: 16px;
}
.file-name {
font-family: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
}
.file-label {
font-size: 12px;
color: var(--text-tertiary);
background: var(--tag-bg);
padding: 2px 8px;
border-radius: 4px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.file-desc-tip {
cursor: help;
font-size: 16px;
}
/* 字型控制 */
.font-size-control {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border-right: 1px solid var(--border-color);
border-left: 1px solid var(--border-color);
margin: 0 4px;
}
.font-size-value {
font-size: 11px;
font-family: var(--mono);
color: var(--text-tertiary);
min-width: 28px;
text-align: center;
}
/* 通用工具栏按钮 */
.tool-btn {
background: var(--hover-bg);
border: 1px solid var(--border-color);
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
line-height: 1;
color: var(--text-secondary);
white-space: nowrap;
}
.tool-btn:hover {
background: var(--active-bg);
color: var(--text-primary);
border-color: var(--primary-color);
}
.reset-btn {
font-size: 14px;
padding: 4px 6px;
}
/* 代码内容区域 */
.code-content {
position: relative;
overflow: auto;
}
.code-content pre {
padding: 16px 20px;
margin: 0;
font-family: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 22px;
color: var(--code-text);
text-align: left;
white-space: pre;
tab-size: 4;
}
.code-content code {
font-family: inherit;
font-size: inherit;
}
/* 全屏模式 */
.code-viewer.fullscreen {
position: fixed;
inset: 0;
z-index: 9999;
border-radius: 0;
background: var(--code-bg);
display: flex;
flex-direction: column;
}
.code-viewer.fullscreen .code-content {
flex: 1;
overflow: auto;
}
.code-viewer.fullscreen .code-content pre {
min-height: 100%;
}
/* 运行按钮 */
.run-btn {
background: #22c55e;
color: white;
border-color: #22c55e;
font-weight: 600;
padding: 4px 12px;
}
.run-btn:hover:not(:disabled) {
background: #16a34a;
border-color: #16a34a;
color: white;
}
.run-btn:disabled {
opacity: 0.6;
cursor: wait;
}
/* 运行输出面板 */
.run-output {
border-top: 1px solid var(--border-color);
background: #1a1a2e;
transition: all 0.2s;
}
.run-output.collapsed .output-body {
display: none;
}
.output-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
user-select: none;
background: rgba(255,255,255,0.03);
}
.output-title {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
}
.output-toggle {
font-size: 11px;
color: #94a3b8;
}
.close-btn {
background: transparent;
border: none;
color: #94a3b8;
padding: 2px 6px;
font-size: 14px;
}
.close-btn:hover {
color: #ef4444;
background: transparent;
}
.output-body {
max-height: 300px;
overflow: auto;
}
.output-body pre {
margin: 0;
padding: 12px 16px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-all;
}
.output-body pre.error-text {
color: #fca5a5;
}
.loading-overlay,
.error-overlay {
padding: 20px;
text-align: center;
color: var(--text-tertiary);
}
.error-overlay {
color: #e74c3c;
}
</style>