新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口 - 行号显示: 文件预览左侧显示行号列 - 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标 - Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图 - Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载) - 提交详情: 点击提交查看变更文件列表及文件级 diff - 时间显示: 提交历史显示相对时间/具体日期 - 面包屑导航不丢预览: 导航时不改变当前预览文件 - 刷新保留选中文件: 只清树缓存不清预览 - 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量 - 自适应布局: 弹性 flex 布局,窄窗口适配 - 滚动条优化: 内容区内部滚动,不溢出页面层 - 多工程管理: 工具栏添加工程按钮+弹窗表单 - 审批加载超时缩短: 130s 降至 30s - 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<!--
|
||||
文件内容预览(Batch 10)。
|
||||
- 文本/代码:<pre><code> 展示(暂不做语法高亮,后续可接 highlight.js / monaco)
|
||||
- 图片:<img> 展示(走 tauri:// 或 convertFileSrc)
|
||||
- 二进制:提示"二进制文件不支持预览"
|
||||
- 顶部:文件路径 + 大小 + Git 状态
|
||||
- 加载态:spinner
|
||||
文件内容预览。
|
||||
- 文本/代码: highlight.js 语法高亮(按文件后缀自动选语言),git 变更可切 diff 视图
|
||||
- 图片: <img> 展示(走 convertFileSrc)
|
||||
- 二进制: 提示"二进制文件不支持预览"
|
||||
- 顶部: 文件路径 + 大小 + Git 状态 + Diff 切换按钮
|
||||
-->
|
||||
<div class="file-preview">
|
||||
<!-- 顶部元信息 -->
|
||||
@@ -19,6 +18,16 @@
|
||||
<div class="preview-meta-right">
|
||||
<span v-if="fileSize !== null" class="preview-size">{{ formatSize(fileSize) }}</span>
|
||||
<span v-if="truncated" class="preview-truncated">⚠ {{ $t('fileExplorer.truncated') }}</span>
|
||||
<!-- Diff 切换按钮(仅 git 有变更时显示) -->
|
||||
<button
|
||||
v-if="gitStatus"
|
||||
class="diff-toggle-btn"
|
||||
:class="{ active: showDiff }"
|
||||
@click="toggleDiff"
|
||||
:title="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
|
||||
>
|
||||
{{ showDiff ? '📄' : '📝' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,39 +58,49 @@
|
||||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||||
</div>
|
||||
|
||||
<!-- 文本/代码 -->
|
||||
<pre v-else class="preview-code"><code>{{ content }}</code></pre>
|
||||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
|
||||
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||
|
||||
<!-- Diff 视图(切到 diff 模式时显示) -->
|
||||
<div v-else-if="showDiff && diffContent" class="preview-diff">
|
||||
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||||
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
||||
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
|
||||
<span class="diff-line-prefix">{{ ln.prefix }}</span>
|
||||
<span class="diff-line-text">{{ ln.text }}</span>
|
||||
</div>
|
||||
<div v-if="diffLoading" class="diff-loading">
|
||||
<span class="spinner"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
||||
<div v-else class="preview-code-wrap">
|
||||
<div class="preview-line-numbers" ref="lineNumRef">
|
||||
<div v-for="n in lineCount" :key="n" class="preview-line-num" :class="{ 'is-active': n === activeLine }">{{ n }}</div>
|
||||
</div>
|
||||
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件预览组件。
|
||||
*
|
||||
* 调用 readModuleFile IPC 拉内容,按返回的 is_binary / 路径后缀决定渲染分支:
|
||||
* - is_binary=true → 二进制提示分支
|
||||
* - 后缀命中图片白名单 → convertFileSrc 走 tauri 资源协议直显(避免大图 base64 撑爆内存)
|
||||
* - 其余 → 文本/代码分支(<pre><code> 保留空白,等宽字体)
|
||||
*
|
||||
* convertFileSrc 用法:把本机绝对文件路径转成 tauri://localhost/... 资源 URL,
|
||||
* 前端 <img> 可直接加载(Tauri 配置需允许该路径;默认 dev/release 均放行应用数据目录,
|
||||
* 工程目录通常不在白名单 → 见下方注意事项,降级用 readModuleFile 拿字节 + dataURL)。
|
||||
*/
|
||||
import { ref, watch, computed, onUnmounted } from 'vue'
|
||||
import { moduleApi } from '@/api/module'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||||
|
||||
const props = defineProps<{
|
||||
moduleId: string
|
||||
/** 相对工程根的文件路径;空/null = 未选文件(显示占位)。 */
|
||||
filePath: string | null
|
||||
/** 工程根绝对路径(用于图片 convertFileSrc 拼绝对路径)。 */
|
||||
moduleRootPath: string
|
||||
/** 该文件的 git 状态(来自文件树条目,可选)。 */
|
||||
gitStatus?: string
|
||||
}>()
|
||||
|
||||
const { } = useMarkdown()
|
||||
const content = ref('')
|
||||
const htmlContent = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isBinary = ref(false)
|
||||
@@ -89,25 +108,123 @@ const fileSize = ref<number | null>(null)
|
||||
const truncated = ref(false)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
/** 图片后缀白名单(命中即走图片分支)。 */
|
||||
/** 行号显示 — 根据 highlight.js 输出的 HTML 行数计算。 */
|
||||
const lineCount = computed(() => {
|
||||
if (!htmlContent.value) return 0
|
||||
// highlight.js 用 \n 分隔行,计算行数
|
||||
const lines = htmlContent.value.split('\n')
|
||||
return lines.length
|
||||
})
|
||||
|
||||
/** 当前激活行(预留:后续可点击行号跳转/选中高亮)。 */
|
||||
const activeLine = ref<number | null>(null)
|
||||
const lineNumRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** Diff 显示控制 */
|
||||
const showDiff = ref(false)
|
||||
const diffContent = ref('')
|
||||
const diffLoading = ref(false)
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'ctx' | 'hdr'
|
||||
prefix: string
|
||||
text: string
|
||||
oldNum: string
|
||||
newNum: string
|
||||
}
|
||||
|
||||
const diffLines = computed<DiffLine[]>(() => {
|
||||
if (!diffContent.value) return []
|
||||
const lines: DiffLine[] = []
|
||||
for (const raw of diffContent.value.split('\n')) {
|
||||
if (raw.startsWith('@@')) {
|
||||
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
|
||||
} else if (raw.startsWith('+')) {
|
||||
lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: '' })
|
||||
} else if (raw.startsWith('-')) {
|
||||
lines.push({ type: 'del', prefix: '-', text: raw.slice(1), oldNum: '', newNum: '' })
|
||||
} else if (raw.startsWith('\\')) {
|
||||
// No newline at end of file 等元信息
|
||||
lines.push({ type: 'ctx', prefix: '', text: raw, oldNum: '', newNum: '' })
|
||||
} else {
|
||||
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
|
||||
}
|
||||
}
|
||||
return lines
|
||||
})
|
||||
|
||||
function toggleDiff() {
|
||||
showDiff.value = !showDiff.value
|
||||
if (showDiff.value && !diffContent.value) {
|
||||
loadDiff()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiff() {
|
||||
if (!props.moduleId || !props.filePath) return
|
||||
diffLoading.value = true
|
||||
try {
|
||||
const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath)
|
||||
diffContent.value = res.diff || ''
|
||||
} catch {
|
||||
diffContent.value = ''
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 外部文件变更 — 通过 df-data-changed 事件接收 AI 工具写入通知,被动触发重载。
|
||||
* 无轮询,零性能开销。外部编辑器修改需用户点击刷新按钮。 */
|
||||
|
||||
/** 文件后缀 → highlight.js 语言名映射。 */
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
rs: 'rust', ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||
vue: 'xml', html: 'xml', xml: 'xml', css: 'css', scss: 'scss',
|
||||
json: 'json', toml: 'ini', yaml: 'yaml', yml: 'yaml',
|
||||
md: 'markdown', py: 'python', go: 'go', java: 'java',
|
||||
sh: 'bash', sql: 'sql',
|
||||
}
|
||||
|
||||
/** 从文件路径推断 highlight.js 语言 class。 */
|
||||
const hljsClass = computed(() => {
|
||||
if (!props.filePath) return 'language-plaintext'
|
||||
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
|
||||
const lang = EXT_LANG[ext] || 'plaintext'
|
||||
return `language-${lang}`
|
||||
})
|
||||
|
||||
/** 图片后缀白名单。 */
|
||||
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']
|
||||
|
||||
/** Markdown 后缀判定。 */
|
||||
const isMarkdown = computed(() => {
|
||||
if (!props.filePath) return false
|
||||
return props.filePath.toLowerCase().endsWith('.md')
|
||||
})
|
||||
|
||||
/** Markdown 渲染(复用 useRendered,含 marked + DOMPurify + 代码高亮)。 */
|
||||
const { rendered: renderedMd, ensureLoaded: ensureMdLoaded } = useRendered(() => content.value)
|
||||
|
||||
const isImage = computed(() => {
|
||||
if (!props.filePath) return false
|
||||
const lower = props.filePath.toLowerCase()
|
||||
return IMAGE_EXT.some((ext) => lower.endsWith(ext))
|
||||
})
|
||||
|
||||
/** 拉文件内容。图片走 blob URL(避免 convertFileSrc 受路径白名单限制)。 */
|
||||
/** 拉文件内容 + 高亮渲染。 */
|
||||
async function loadFile() {
|
||||
if (!props.filePath) {
|
||||
content.value = ''
|
||||
htmlContent.value = ''
|
||||
imageUrl.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
// 释放上一张图的 blob URL(防内存泄漏)。
|
||||
// Markdown 文件触发预热(确保 marked 就绪,useRendered computed 据此渲染)
|
||||
if (props.filePath.toLowerCase().endsWith('.md')) {
|
||||
ensureMdLoaded()
|
||||
}
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = null
|
||||
@@ -119,17 +236,14 @@ async function loadFile() {
|
||||
isBinary.value = res.is_binary
|
||||
if (res.is_binary) {
|
||||
content.value = ''
|
||||
htmlContent.value = ''
|
||||
return
|
||||
}
|
||||
if (isImage.value) {
|
||||
// 图片:把文本内容(其实后端对图片也走 lossy 转 string,这里不可靠)→ 改走
|
||||
// convertFileSrc 拿到本机资源 URL。失败时 fallback 到文本分支(至少不崩)。
|
||||
// 注:Tauri 默认放行 app data dir;工程目录若不在白名单会显示 broken image,
|
||||
// 这是已知降级,后续可通过 tauri.conf.json 的 assetProtocol 放行工程目录。
|
||||
content.value = ''
|
||||
htmlContent.value = ''
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
// 拼绝对路径:moduleRootPath + filePath(POSIX → 平台分隔符)。
|
||||
const abs = joinPath(props.moduleRootPath, props.filePath)
|
||||
imageUrl.value = convertFileSrc(abs)
|
||||
} catch {
|
||||
@@ -137,6 +251,19 @@ async function loadFile() {
|
||||
}
|
||||
} else {
|
||||
content.value = res.content
|
||||
// highlight.js 语法高亮:已知语言精确高亮,未知语言自动检测
|
||||
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
|
||||
const lang = EXT_LANG[ext]
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
htmlContent.value = hljs.highlight(res.content, { language: lang }).value
|
||||
} else {
|
||||
htmlContent.value = hljs.highlightAuto(res.content).value
|
||||
}
|
||||
} catch {
|
||||
// 高亮失败 → 转义纯文本(防 XSS)
|
||||
htmlContent.value = escapeHtml(res.content)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
@@ -145,7 +272,10 @@ async function loadFile() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 拼接工程根 + 相对路径为绝对路径(平台分隔符)。 */
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function joinPath(root: string, rel: string): string {
|
||||
const sep = root.includes('\\') ? '\\' : '/'
|
||||
const normRel = rel.split('/').join(sep)
|
||||
@@ -153,7 +283,6 @@ function joinPath(root: string, rel: string): string {
|
||||
return root + sep + normRel
|
||||
}
|
||||
|
||||
/** 字节数 → 人类可读大小。 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
@@ -178,7 +307,11 @@ function gitStatusLabel(status: string): string {
|
||||
return x.charAt(0) || '?'
|
||||
}
|
||||
|
||||
watch(() => [props.moduleId, props.filePath], loadFile, { immediate: true })
|
||||
watch(() => [props.moduleId, props.filePath], () => {
|
||||
showDiff.value = false
|
||||
diffContent.value = ''
|
||||
loadFile()
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
|
||||
@@ -222,15 +355,8 @@ onUnmounted(() => {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.preview-size {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-truncated {
|
||||
color: #f0a020;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-size { color: var(--df-text-dim); flex-shrink: 0; }
|
||||
.preview-truncated { color: #f0a020; flex-shrink: 0; }
|
||||
|
||||
.git-badge {
|
||||
flex-shrink: 0;
|
||||
@@ -242,31 +368,19 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.git-modified {
|
||||
background: rgba(255, 165, 0, 0.18);
|
||||
color: #f0a020;
|
||||
}
|
||||
.git-added {
|
||||
background: rgba(60, 180, 80, 0.18);
|
||||
color: #4caf50;
|
||||
}
|
||||
.git-untracked {
|
||||
background: rgba(150, 150, 150, 0.18);
|
||||
color: #999;
|
||||
}
|
||||
.git-deleted {
|
||||
background: rgba(220, 60, 60, 0.18);
|
||||
color: #e05050;
|
||||
}
|
||||
.git-other {
|
||||
background: rgba(100, 150, 220, 0.18);
|
||||
color: #6a9adc;
|
||||
}
|
||||
.git-modified { background: rgba(255, 165, 0, 0.18); color: #f0a020; }
|
||||
.git-added { background: rgba(60, 180, 80, 0.18); color: #4caf50; }
|
||||
.git-untracked { background: rgba(150, 150, 150, 0.18); color: #999; }
|
||||
.git-deleted { background: rgba(220, 60, 60, 0.18); color: #e05050; }
|
||||
.git-other { background: rgba(100, 150, 220, 0.18); color: #6a9adc; }
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-placeholder,
|
||||
@@ -284,14 +398,8 @@ onUnmounted(() => {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
color: #e05050;
|
||||
}
|
||||
|
||||
.binary-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.preview-error { color: #e05050; }
|
||||
.binary-icon { font-size: 32px; opacity: 0.5; }
|
||||
|
||||
.preview-image {
|
||||
display: flex;
|
||||
@@ -314,14 +422,59 @@ onUnmounted(() => {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--df-text);
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
tab-size: 2;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-code code {
|
||||
font-family: inherit;
|
||||
.preview-code code { font-family: inherit; }
|
||||
|
||||
/* 行号 + 代码并排容器 */
|
||||
.preview-code-wrap {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* 行号列 */
|
||||
.preview-line-numbers {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 0;
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-right: 0.5px solid var(--df-border);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-line-num {
|
||||
padding: 0 10px 0 8px;
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.preview-line-num.is-active {
|
||||
color: var(--df-accent);
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Diff 视图 — 在自身区域滚动,不溢出到父级 */
|
||||
.preview-diff {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@@ -334,9 +487,94 @@ onUnmounted(() => {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
/* Diff 切换条 */
|
||||
.diff-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff-toggle-btn:hover {
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.diff-toggle-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* Diff 视图 */
|
||||
.preview-diff {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.diff-line-num {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.4;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-line-prefix {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-line-text {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
background: rgba(40, 160, 70, 0.12);
|
||||
}
|
||||
|
||||
.diff-add .diff-line-prefix { color: #4caf50; }
|
||||
|
||||
.diff-del {
|
||||
background: rgba(220, 60, 60, 0.12);
|
||||
}
|
||||
|
||||
.diff-del .diff-line-prefix { color: #e05050; }
|
||||
|
||||
.diff-hdr {
|
||||
background: rgba(60, 140, 220, 0.08);
|
||||
color: var(--df-text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.diff-ctx {
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<!-- highlight.js github-dark 主题(全局,非 scoped) -->
|
||||
<style src="highlight.js/styles/github-dark.css"></style>
|
||||
|
||||
Reference in New Issue
Block a user