451 lines
10 KiB
Vue
451 lines
10 KiB
Vue
<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>
|