Files
suanfa/src/components/CodeViewer.vue
T

451 lines
10 KiB
Vue
Raw Normal View History

2026-06-15 09:00:38 +08:00
<template>
2026-06-15 10:59:56 +08:00
<div class="code-viewer" ref="viewerRef" :class="{ fullscreen: isFullscreen }">
2026-06-15 09:00:38 +08:00
<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>
2026-06-15 10:59:56 +08:00
<!-- 字号控制 -->
<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"
2026-06-16 09:35:51 +08:00
:title="running ? '正在运行...' : `运行${language === 'python' ? ' Python' : ' C'}代码`"
2026-06-15 10:59:56 +08:00
>
{{ running ? '⏳' : '▶' }} 运行
</button>
<!-- 复制 -->
<button class="tool-btn" @click="copyCode" :title="copied ? '已复制!' : '复制代码'">
2026-06-15 09:00:38 +08:00
{{ copied ? '✅' : '📋' }}
</button>
</div>
</div>
2026-06-15 10:59:56 +08:00
<div class="code-content" ref="codeRef" @wheel="onWheel">
<pre :style="{ fontSize: fontSize + 'px', lineHeight: lineHeight + 'px' }"><code v-html="highlightedCode"></code></pre>
2026-06-15 09:00:38 +08:00
<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>
2026-06-15 10:59:56 +08:00
<!-- 运行输出面板 -->
<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>
2026-06-15 09:00:38 +08:00
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
2026-06-16 09:35:51 +08:00
import { loadCodeFile, highlightC, highlightPython } from '../utils/codeUtils.js'
2026-06-15 09:00:38 +08:00
const props = defineProps({
filePath: String,
fileName: String,
fileLabel: String,
2026-06-16 09:35:51 +08:00
description: String,
language: { type: String, default: 'c' } // 'c' | 'python'
2026-06-15 09:00:38 +08:00
})
const codeContent = ref('')
const loading = ref(false)
const error = ref('')
const copied = ref(false)
2026-06-15 10:59:56 +08:00
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
2026-06-15 09:00:38 +08:00
const highlightedCode = computed(() => {
if (!codeContent.value) return ''
2026-06-16 09:35:51 +08:00
if (props.language === 'python') return highlightPython(codeContent.value)
2026-06-15 09:00:38 +08:00
return highlightC(codeContent.value)
})
2026-06-15 10:59:56 +08:00
const lineHeight = computed(() => Math.max(22, fontSize.value * 1.5))
2026-06-15 09:00:38 +08:00
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 })
2026-06-15 10:59:56 +08:00
// 字号控制
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
2026-06-16 09:35:51 +08:00
const apiEndpoint = props.language === 'python' ? '/api/run-py' : '/api/run-c'
2026-06-15 10:59:56 +08:00
try {
2026-06-16 09:35:51 +08:00
const res = await fetch(apiEndpoint, {
2026-06-15 10:59:56 +08:00
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
}
}
2026-06-15 09:00:38 +08:00
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;
2026-06-15 10:59:56 +08:00
gap: 6px;
2026-06-15 09:00:38 +08:00
}
.file-desc-tip {
cursor: help;
font-size: 16px;
}
2026-06-15 10:59:56 +08:00
/* 字型控制 */
.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 {
2026-06-15 09:00:38 +08:00
background: var(--hover-bg);
border: 1px solid var(--border-color);
2026-06-15 10:59:56 +08:00
padding: 4px 8px;
2026-06-15 09:00:38 +08:00
border-radius: 6px;
cursor: pointer;
2026-06-15 10:59:56 +08:00
font-size: 13px;
transition: all 0.15s;
line-height: 1;
color: var(--text-secondary);
white-space: nowrap;
2026-06-15 09:00:38 +08:00
}
2026-06-15 10:59:56 +08:00
.tool-btn:hover {
2026-06-15 09:00:38 +08:00
background: var(--active-bg);
2026-06-15 10:59:56 +08:00
color: var(--text-primary);
border-color: var(--primary-color);
2026-06-15 09:00:38 +08:00
}
2026-06-15 10:59:56 +08:00
.reset-btn {
font-size: 14px;
padding: 4px 6px;
}
/* 代码内容区域 */
2026-06-15 09:00:38 +08:00
.code-content {
position: relative;
2026-06-15 10:59:56 +08:00
overflow: auto;
2026-06-15 09:00:38 +08:00
}
.code-content pre {
2026-06-15 10:59:56 +08:00
padding: 16px 20px;
2026-06-15 09:00:38 +08:00
margin: 0;
font-family: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
font-size: 13px;
2026-06-15 10:59:56 +08:00
line-height: 22px;
2026-06-15 09:00:38 +08:00
color: var(--code-text);
2026-06-15 10:59:56 +08:00
text-align: left;
white-space: pre;
tab-size: 4;
2026-06-15 09:00:38 +08:00
}
.code-content code {
font-family: inherit;
2026-06-15 10:59:56 +08:00
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;
2026-06-15 09:00:38 +08:00
}
.loading-overlay,
.error-overlay {
padding: 20px;
text-align: center;
color: var(--text-tertiary);
}
.error-overlay {
color: #e74c3c;
}
</style>