update
This commit is contained in:
104
app/components/content/Alert.vue
Normal file
104
app/components/content/Alert.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
type AlertType = "question" | "info" | "warning" | "error";
|
||||
|
||||
interface Props {
|
||||
type?: AlertType;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
card?: boolean;
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: "info",
|
||||
card: false,
|
||||
flat: false,
|
||||
});
|
||||
|
||||
const typeConfig = {
|
||||
question: {
|
||||
bgLight: "bg-blue-500/10",
|
||||
bgDark: "bg-blue-500/20",
|
||||
borderColor: "border-blue-500/50",
|
||||
textColor: "text-blue-400",
|
||||
accentColor: "text-blue-300",
|
||||
icon: "mdi:help-circle",
|
||||
},
|
||||
info: {
|
||||
bgLight: "bg-cyan-500/10",
|
||||
bgDark: "bg-cyan-500/20",
|
||||
borderColor: "border-cyan-500/50",
|
||||
textColor: "text-cyan-400",
|
||||
accentColor: "text-cyan-300",
|
||||
icon: "mdi:information",
|
||||
},
|
||||
warning: {
|
||||
bgLight: "bg-amber-500/10",
|
||||
bgDark: "bg-amber-500/20",
|
||||
borderColor: "border-amber-500/50",
|
||||
textColor: "text-amber-400",
|
||||
accentColor: "text-amber-300",
|
||||
icon: "mdi:alert",
|
||||
},
|
||||
error: {
|
||||
bgLight: "bg-red-500/10",
|
||||
bgDark: "bg-red-500/20",
|
||||
borderColor: "border-red-500/50",
|
||||
textColor: "text-red-400",
|
||||
accentColor: "text-red-300",
|
||||
icon: "mdi:close-circle",
|
||||
},
|
||||
};
|
||||
|
||||
const config = computed(() => typeConfig[props.type]);
|
||||
|
||||
const defaultTitles = {
|
||||
question: "问题",
|
||||
info: "提示",
|
||||
warning: "警告",
|
||||
error: "错误",
|
||||
};
|
||||
|
||||
const displayTitle = computed(() => props.title || defaultTitles[props.type]);
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const base = "rounded-lg transition-all duration-200";
|
||||
const background = props.flat ? "bg-transparent" : config.value.bgLight;
|
||||
const border = `border ${config.value.borderColor}`;
|
||||
|
||||
if (props.card) {
|
||||
return `${base} ${border} ${background} p-4 shadow-md hover:shadow-lg`;
|
||||
}
|
||||
|
||||
return `${base} ${border} ${background} p-3`;
|
||||
});
|
||||
|
||||
const iconName = computed(() => props.icon || config.value.icon);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<div class="flex gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="shrink-0 w-5 h-5 mt-0.5">
|
||||
<Icon :name="iconName" :class="config.textColor" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Title -->
|
||||
<div :class="[config.textColor, 'font-semibold text-sm mb-1']">
|
||||
<slot name="title">{{ displayTitle }}</slot>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="text-sm text-white/85 leading-relaxed">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
129
app/components/content/GithubCard.vue
Normal file
129
app/components/content/GithubCard.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
interface Repo {
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description?: string;
|
||||
stargazers_count?: number;
|
||||
forks_count?: number;
|
||||
language?: string;
|
||||
updated_at?: string;
|
||||
owner?: { login: string; avatar_url?: string; html_url?: string };
|
||||
license?: { name?: string };
|
||||
}
|
||||
|
||||
const props = defineProps<{ repo: string }>();
|
||||
const repoId = computed(() => props.repo.trim());
|
||||
|
||||
// parse owner/repo
|
||||
const ownerRepo = computed(() => {
|
||||
const parts = repoId.value
|
||||
.split("/")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length === 2 ? { owner: parts[0], name: parts[1] } : null;
|
||||
});
|
||||
|
||||
const key = computed(() => `github-${repoId.value}`);
|
||||
const url = computed(() => {
|
||||
if (ownerRepo.value && ownerRepo.value.owner && ownerRepo.value.name) {
|
||||
return `https://api.github.com/repos/${encodeURIComponent(ownerRepo.value.owner)}/${encodeURIComponent(ownerRepo.value.name)}`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const { data, pending, error } = await useAsyncData<Repo | null>(
|
||||
() => key.value,
|
||||
() => {
|
||||
if (!ownerRepo.value) {
|
||||
throw new Error("repo prop must be in form owner/name");
|
||||
}
|
||||
return $fetch<Repo>(url.value);
|
||||
},
|
||||
{ watch: [repoId] },
|
||||
);
|
||||
|
||||
const repoData = computed(() => data.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<div
|
||||
v-if="!pending && !error && repoData"
|
||||
class="block rounded-xl p-3.5 md:p-4 bg-linear-to-b from-white/2 to-white/1 text-white/90 border border-white/4 shadow-lg hover:shadow-2xl hover:-translate-y-1.5 transition-all duration-180">
|
||||
<a
|
||||
:href="repoData.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-col gap-0 no-underline text-inherit cursor-pointer">
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="flex flex-col flex-1">
|
||||
<p class="inline-block no-underline text-inherit cursor-pointer group">
|
||||
<strong
|
||||
class="text-sm font-semibold leading-tight group-hover:text-blue-400 transition-colors"
|
||||
>{{ repoData.full_name }}</strong
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span
|
||||
v-if="repoData.language"
|
||||
class="inline-block bg-white/3 bg-linear-to-r from-white/2 to-white/1 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
||||
>{{ repoData.language }}</span
|
||||
>
|
||||
<span
|
||||
v-if="repoData.stargazers_count"
|
||||
class="inline-block bg-white/3 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
||||
>★ {{ repoData.stargazers_count }}</span
|
||||
>
|
||||
<span
|
||||
v-if="repoData.forks_count"
|
||||
class="inline-block bg-white/3 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
||||
>⑂ {{ repoData.forks_count }}</span
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
:href="repoData.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 flex items-center justify-center p-1 no-underline text-inherit cursor-pointer hover:opacity-100 transition-all duration-180">
|
||||
<svg
|
||||
class="opacity-70 hover:opacity-100 hover:scale-110 transition-all duration-180"
|
||||
viewBox="0 0 16 16"
|
||||
width="28"
|
||||
height="28"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="repoData.description" class="mt-2 text-white/85 text-sm line-clamp-3">
|
||||
{{ repoData.description }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 text-xs text-white/60 flex gap-2.5">
|
||||
<span v-if="repoData.license?.name">{{ repoData.license.name }}</span>
|
||||
<span v-if="repoData.updated_at"
|
||||
>· 更新于 {{ new Date(repoData.updated_at).toLocaleDateString() }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pending"
|
||||
class="block rounded-xl p-3.5 md:p-4 bg-linear-to-b from-white/2 to-white/1 text-center text-white/60 text-sm">
|
||||
加载 GitHub 仓库信息...
|
||||
</div>
|
||||
|
||||
<div v-else class="block rounded-xl p-3.5 md:p-4 bg-white/1 text-center text-white/60 text-sm">
|
||||
无法获取仓库信息({{ props.repo }})。
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
302
app/components/content/ProsePre.vue
Normal file
302
app/components/content/ProsePre.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
|
||||
interface CodeblockMeta {
|
||||
icon?: string;
|
||||
wrap?: boolean;
|
||||
indent?: string | boolean;
|
||||
expand?: boolean;
|
||||
[meta: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
code: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: "text",
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
highlights: {
|
||||
type: Array as () => number[],
|
||||
default: () => [],
|
||||
},
|
||||
meta: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
class: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const meta = computed(() => {
|
||||
if (!props.meta) return {};
|
||||
|
||||
return props.meta.split(" ").reduce((acc: CodeblockMeta, item) => {
|
||||
const [key, value] = item.split("=");
|
||||
acc[key!] = value ?? true;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const rows = computed(() => props.code.split("\n").length - 1);
|
||||
const collapsible = computed(() => !meta.value.expand && rows.value > 20);
|
||||
const [isCollapsed, toggleCollapsed] = useToggle(collapsible.value);
|
||||
|
||||
const isWrap = ref(meta.value.wrap ?? false);
|
||||
|
||||
const codeblock = useTemplateRef("codeblock");
|
||||
const copied = ref(false);
|
||||
|
||||
async function copy() {
|
||||
if (!codeblock.value) return;
|
||||
try {
|
||||
const text = codeblock.value.textContent || "";
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("复制失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function getFileIcon(filename?: string): string | undefined {
|
||||
if (!filename) return undefined;
|
||||
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
const iconMap: Record<string, string> = {
|
||||
json: "i-ph-file-json",
|
||||
ts: "i-ph-file-ts",
|
||||
js: "i-ph-file-js",
|
||||
vue: "i-ph-file-vue",
|
||||
py: "i-ph-file-py",
|
||||
sh: "i-ph-terminal-window-duotone",
|
||||
bash: "i-ph-terminal-window-duotone",
|
||||
yml: "i-ph-file-yaml",
|
||||
yaml: "i-ph-file-yaml",
|
||||
md: "i-ph-file-md",
|
||||
html: "i-ph-file-html",
|
||||
css: "i-ph-file-css",
|
||||
xml: "i-ph-file-xml",
|
||||
};
|
||||
|
||||
return iconMap[ext!];
|
||||
}
|
||||
|
||||
function getLangIcon(language?: string): string | undefined {
|
||||
if (!language) return undefined;
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
javascript: "i-ph-file-js",
|
||||
typescript: "i-ph-file-ts",
|
||||
python: "i-ph-file-py",
|
||||
json: "i-ph-file-json",
|
||||
bash: "i-ph-terminal-window-duotone",
|
||||
shell: "i-ph-terminal-window-duotone",
|
||||
sh: "i-ph-terminal-window-duotone",
|
||||
vue: "i-ph-file-vue",
|
||||
html: "i-ph-file-html",
|
||||
css: "i-ph-file-css",
|
||||
scss: "i-ph-file-css",
|
||||
yaml: "i-ph-file-yaml",
|
||||
yml: "i-ph-file-yaml",
|
||||
xml: "i-ph-file-xml",
|
||||
markdown: "i-ph-file-md",
|
||||
md: "i-ph-file-md",
|
||||
};
|
||||
|
||||
return iconMap[language];
|
||||
}
|
||||
|
||||
const icon = computed(() => {
|
||||
return meta.value.icon || getFileIcon(props.filename) || getLangIcon(props.language);
|
||||
});
|
||||
|
||||
// 为每一行添加 data-line 属性并隐藏首尾空行
|
||||
if (import.meta.client) {
|
||||
onMounted(() => {
|
||||
if (!codeblock.value) return;
|
||||
|
||||
const lines = codeblock.value.querySelectorAll(".line");
|
||||
let firstNonEmptyIndex = -1;
|
||||
let lastNonEmptyIndex = -1;
|
||||
|
||||
// 找到第一个和最后一个非空行
|
||||
lines.forEach((line, index) => {
|
||||
const text = (line.textContent || "").trim();
|
||||
if (text) {
|
||||
if (firstNonEmptyIndex === -1) firstNonEmptyIndex = index;
|
||||
lastNonEmptyIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
// 隐藏首尾空行
|
||||
lines.forEach((line, index) => {
|
||||
// 设置行号
|
||||
if (!line.getAttribute("data-line")) {
|
||||
line.setAttribute("data-line", String(index + 1));
|
||||
}
|
||||
|
||||
// 隐藏首尾空行
|
||||
if (index < firstNonEmptyIndex || index > lastNonEmptyIndex) {
|
||||
(line as HTMLElement).style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure
|
||||
class="group relative overflow-hidden my-4 rounded-lg bg-(--c-bg-2,#0f1419) text-sm leading-relaxed border border-(--c-border,#2a2e38)"
|
||||
:class="{
|
||||
'is-collapsed': collapsible && isCollapsed,
|
||||
'is-collapsible': collapsible,
|
||||
}"
|
||||
:style="{ '--tab-size': (meta.indent as string) || 4 }">
|
||||
<!-- 顶部标题栏 -->
|
||||
<figcaption
|
||||
class="sticky top-0 z-20 flex items-center justify-between gap-3 px-4 py-2.5 bg-(--c-bg-2,#0f1419) border-b border-(--c-border,#2a2e38) backdrop-blur-sm">
|
||||
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<!-- 文件名 -->
|
||||
<span
|
||||
v-if="filename"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-(--c-bg-3,#1a1f2e) text-xs font-mono text-(--c-text-1,#e8eaed) whitespace-nowrap border border-(--c-border,#2a2e38)">
|
||||
<Icon v-if="icon" :name="icon" class="w-3.5 h-3.5 shrink-0 opacity-80" />
|
||||
<span class="truncate font-semibold">{{ filename }}</span>
|
||||
</span>
|
||||
<!-- 语言标签 -->
|
||||
<span
|
||||
v-if="language"
|
||||
class="text-xs text-(--c-text-2,#a8adb8) font-mono font-medium tracking-wide">
|
||||
{{ language }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="px-2.5 py-1.5 rounded-md text-xs font-mono bg-(--c-bg-3,#1a1f2e) hover:bg-(--c-border,#2a2e38) transition-colors text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) border border-transparent hover:border-(--c-border,#2a2e38)"
|
||||
title="切换代码换行"
|
||||
@click="isWrap = !isWrap">
|
||||
{{ isWrap ? "换行" : "滚动" }}
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1.5 rounded-md text-xs font-mono bg-(--c-bg-3,#1a1f2e) hover:bg-(--c-border,#2a2e38) transition-colors text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) border border-transparent hover:border-(--c-border,#2a2e38)"
|
||||
title="复制代码"
|
||||
@click="copy()">
|
||||
{{ copied ? "✓ 已复制" : "复制" }}
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
|
||||
<!-- 代码块 -->
|
||||
<pre
|
||||
ref="codeblock"
|
||||
class="overflow-auto m-0 text-(--c-text-1,#e8eaed) shiki"
|
||||
:style="{
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingTop: '0.3rem',
|
||||
paddingBottom: '0.3rem',
|
||||
// paddingBottom: collapsible ? '3rem' : '0.5rem',
|
||||
fontSize: '14px',
|
||||
tabSize: (meta.indent as string) || '4',
|
||||
lineHeight: '1.5',
|
||||
}"
|
||||
:class="[props.class, { 'whitespace-pre-wrap': isWrap }]">
|
||||
<slot />
|
||||
</pre>
|
||||
|
||||
<!-- 折叠/展开按钮 -->
|
||||
<button
|
||||
v-if="collapsible"
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-2 rounded-lg bg-(--c-bg-3,#1a1f2e) border border-(--c-border,#2a2e38) text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) hover:bg-(--c-border,#2a2e38) transition-all duration-200 cursor-pointer flex items-center gap-2 text-xs font-mono font-medium"
|
||||
:aria-label="isCollapsed ? '展开代码块' : '折叠代码块'"
|
||||
@click="toggleCollapsed()">
|
||||
<Icon
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isCollapsed }"
|
||||
name="ph:caret-down-bold" />
|
||||
<span>{{ rows }} 行</span>
|
||||
</button>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.is-collapsed {
|
||||
:deep(pre) {
|
||||
max-height: calc(1.5em * 20 + 3.5rem);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(pre::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3.5rem;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(15, 20, 25, 0.3) 60%,
|
||||
var(--c-bg-2, #0f1419) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.line) {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding-left: 3.5rem;
|
||||
}
|
||||
|
||||
:deep(.line::before) {
|
||||
content: attr(data-line);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 3rem;
|
||||
padding-right: 0.5rem;
|
||||
text-align: right;
|
||||
color: var(--c-text-3, #6c7280);
|
||||
user-select: none;
|
||||
font-size: 0.85em;
|
||||
font-family: "Fira Code", "Monaco", monospace;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:deep(.line:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.line.highlight) {
|
||||
background-color: rgba(248, 113, 113, 0.08);
|
||||
border-left: 2px solid rgba(248, 113, 113, 0.3);
|
||||
margin-left: -3.5rem;
|
||||
padding-left: calc(3.5rem - 2px);
|
||||
}
|
||||
|
||||
:deep(.line.highlight::before) {
|
||||
color: var(--c-text-2, #a8adb8);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
font-family: "Fira Code", "Monaco", monospace;
|
||||
font-size: 0.95em;
|
||||
letter-spacing: 0.01em;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
}
|
||||
</style>
|
||||
@@ -1,159 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 代码块组件 Props 定义
|
||||
*/
|
||||
interface Props {
|
||||
code?: string; // 代码内容
|
||||
language?: string; // 编程语言
|
||||
filename?: string; // 文件名
|
||||
highlights?: number[]; // 需要高亮的行号
|
||||
meta?: string; // 元数据
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
code: "",
|
||||
language: undefined,
|
||||
filename: undefined,
|
||||
highlights: () => [],
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
// 剪贴板复制功能
|
||||
const { copy, copied } = useClipboard({ source: props.code });
|
||||
|
||||
// macOS 窗口控制按钮颜色配置
|
||||
const WINDOW_CONTROLS = [
|
||||
{ color: "#ff5f56", label: "close" },
|
||||
{ color: "#ffbd2e", label: "minimize" },
|
||||
{ color: "#27c93f", label: "maximize" },
|
||||
] as const;
|
||||
|
||||
// 是否显示头部(当有文件名或语言信息时显示)
|
||||
const showHeader = computed(() => props.filename || props.language);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative my-6 overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-50/50 shadow-sm dark:border-white/5 dark:bg-[#282a36] dark:shadow-2xl">
|
||||
<!-- 代码块头部 -->
|
||||
<header
|
||||
v-if="showHeader"
|
||||
class="flex items-center justify-between border-b border-zinc-200 bg-zinc-100/60 px-4 py-2.5 dark:border-white/5 dark:bg-white/5">
|
||||
<!-- 左侧:窗口控制按钮 + 文件名 -->
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<!-- macOS 风格的窗口控制按钮 -->
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<span
|
||||
v-for="control in WINDOW_CONTROLS"
|
||||
:key="control.label"
|
||||
:style="{ backgroundColor: control.color }"
|
||||
class="h-3 w-3 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.2)]"
|
||||
:aria-label="control.label" />
|
||||
</div>
|
||||
|
||||
<!-- 文件名显示 -->
|
||||
<span
|
||||
v-if="filename"
|
||||
class="ml-2 max-w-50 truncate font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{{ filename }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:语言标签 + 复制按钮 -->
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<!-- 编程语言标签 -->
|
||||
<span
|
||||
v-if="language"
|
||||
class="text-[0.625rem] font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-500">
|
||||
{{ language }}
|
||||
</span>
|
||||
|
||||
<!-- 复制按钮 -->
|
||||
<button
|
||||
aria-label="复制代码"
|
||||
class="rounded-md p-1.5 text-zinc-700 transition-all duration-200 hover:bg-gray-200/40 hover:text-zinc-900 active:scale-95 dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
:class="{ 'text-emerald-500 dark:text-emerald-400': copied }"
|
||||
:title="copied ? '已复制' : '复制代码'"
|
||||
@click="copy()">
|
||||
<Icon :name="copied ? 'heroicons:check' : 'heroicons:clipboard'" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 代码内容区域 -->
|
||||
<div class="relative">
|
||||
<pre
|
||||
class="custom-scrollbar m-0! text-zinc-700 dark:text-white! overflow-x-auto bg-transparent! p-4! font-mono text-sm leading-relaxed"><slot /></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* 行号功能 */
|
||||
:deep(code) {
|
||||
counter-reset: line-number;
|
||||
display: grid;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.line) {
|
||||
display: inline-flex;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
:deep(.line::before) {
|
||||
content: counter(line-number);
|
||||
counter-increment: line-number;
|
||||
width: 2rem;
|
||||
margin-right: 1.5rem;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
color: rgba(40, 42, 54, 0.3);
|
||||
user-select: none;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.line::before) {
|
||||
color: #6272a4 !important;
|
||||
}
|
||||
|
||||
/* 代码行高亮 */
|
||||
:deep(.line.highlight) {
|
||||
background-color: rgba(var(--site-primary-rgb), 0.12);
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
border-left: 2px solid var(--site-primary);
|
||||
width: calc(100% + 2rem);
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.line.highlight) {
|
||||
background-color: rgba(var(--site-primary-rgb), 0.08);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user