✨ 主页布局构建完成
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { DataAnomaly, toArticleMetaDataType } from '~/types/ArticleMetaData';
|
||||
import type { ArticleMetaData } from '~/types/ArticleMetaData';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
article?: ArticleMetaData;
|
||||
}>(),
|
||||
{
|
||||
article: () => toArticleMetaDataType({
|
||||
id: 'default ID',
|
||||
title: 'ApiFox / Postman 使用WebSocket连接SignalR进行测试需要注意的小问题',
|
||||
description: 'default Description',
|
||||
created_at: new Date('2025-01-01T00:00:00Z'), // 默认创建时间
|
||||
published_at: new Date('2025-01-01T00:01:00Z'), // 默认发布时间
|
||||
draft: true,
|
||||
updated_at: [new Date('2025-01-01T00:02:00Z'), new Date('2025-01-01T00:03:00Z')], // 默认更新时间
|
||||
tags: ['C#', 'TS', 'Windows Professional version with Webstorm 2025'],
|
||||
tech_stack: new Map([
|
||||
['Vue', 5],
|
||||
['Nuxt', 3],
|
||||
['TypeScript', 4],
|
||||
['JavaScript', 2],
|
||||
['CSS', 1],
|
||||
['HTML', 1],
|
||||
['Node.js', 2],
|
||||
['Python', 3],
|
||||
['Java', 2],
|
||||
['C#', 1],
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
function dateFormat(date: Date | DataAnomaly) {
|
||||
if (date === DataAnomaly.DataNotFound || date === DataAnomaly.Invalid) {
|
||||
return date;
|
||||
}
|
||||
return new Date(date).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// const { colorMode } = storeToRefs(useColorModeStore());
|
||||
// console.log(props.article.tech_stack)
|
||||
|
||||
function getCostTime(length: number | DataAnomaly) {
|
||||
if (length === DataAnomaly.DataNotFound || length === DataAnomaly.Invalid) {
|
||||
return length;
|
||||
}
|
||||
let time = length / 250;
|
||||
time = Math.ceil(time);
|
||||
const hours = Math.floor(time / 60);
|
||||
const minutes = time % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分钟`;
|
||||
} else {
|
||||
return `${minutes}分钟`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5 light:bg-old-neutral-200 dark:bg-old-neutral-800 min-h-64">
|
||||
<div class="text-4xl">
|
||||
{{ props.article.title }}
|
||||
</div>
|
||||
<div class="flex items-center mt-2">
|
||||
|
||||
<div title="发布时间" class="flex items-center">
|
||||
<Icon name="lucide:clock-arrow-up"/>
|
||||
<div class="ml-1">
|
||||
{{ dateFormat(props.article.published_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div title="字数" class="flex items-center ml-2">
|
||||
<Icon name="fluent:text-word-count-20-filled"/>
|
||||
<div class="ml-1 inline">
|
||||
{{ props.article.word_count }}字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div title="预计阅读时间" class="flex items-center ml-2">
|
||||
<Icon name="octicon:stopwatch-16"/>
|
||||
<div class="ml-1 inline">
|
||||
{{ getCostTime(props.article.word_count) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex mt-2 justify-between h-28">
|
||||
<div>
|
||||
<div class="">
|
||||
{{ props.article.description }}
|
||||
</div>
|
||||
</div>
|
||||
<TechStackCard
|
||||
:async-key="props.article.id"
|
||||
:tech-stack="props.article.tech_stack"
|
||||
:tech-stack-icon-names="props.article.tech_stack_icon_names"
|
||||
:tech-stack-theme-colors="props.article.tech_stack_theme_colors"
|
||||
:tech-stack-percent="props.article.tech_stack_percent"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="flex mt-2">
|
||||
<div title="创建时间" class="flex items-center">
|
||||
<Icon name="lucide:file-clock"/>
|
||||
<div class="ml-1">{{ dateFormat(props.article.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="Array.isArray(props.article.updated_at)" class="flex items-center ml-2">
|
||||
<Icon name="lucide:clock-alert" title="上次更新时间"/>
|
||||
<HoverContent>
|
||||
<template #content>
|
||||
<div class="ml-1">
|
||||
{{ dateFormat(props.article.updated_at[props.article.updated_at.length - 1]) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #hoverContent>
|
||||
<div class="p-1 pr-2">
|
||||
<div v-for="(date,index) of props.article.updated_at" :key="index">
|
||||
<div class="block whitespace-nowrap">
|
||||
{{ '第' + index + '次更新' + dateFormat(date) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoverContent>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Array.isArray(props.article.tags)" class="flex items-center">
|
||||
<Icon name="clarity:tags-solid"/>
|
||||
<div v-for="(tag,index) of props.article.tags" :key="index">
|
||||
<div class="ml-1">{{ tag }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const hoverContentRef = ref<HTMLDivElement>();
|
||||
const position = ref({
|
||||
top: true, // true: 下方, false: 上方
|
||||
left: true, // true: 右对齐, false: 左对齐
|
||||
});
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!containerRef.value || !hoverContentRef.value) return;
|
||||
|
||||
const container = containerRef.value.getBoundingClientRect();
|
||||
const hoverContent = hoverContentRef.value.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
// 计算垂直位置:优先下方,空间不足则上方
|
||||
const spaceBelow = viewport.height - container.bottom;
|
||||
const spaceAbove = container.top;
|
||||
position.value.top = spaceBelow >= hoverContent.height || spaceBelow >= spaceAbove;
|
||||
|
||||
// 计算水平位置:优先左对齐,空间不足则右对齐
|
||||
const spaceRight = viewport.width - container.left;
|
||||
position.value.left = spaceRight >= hoverContent.width;
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// 延迟一帧确保元素已渲染
|
||||
requestAnimationFrame(updatePosition);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updatePosition);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative group"
|
||||
@mouseenter="handleMouseEnter"
|
||||
>
|
||||
<slot name="content" />
|
||||
<div
|
||||
ref="hoverContentRef"
|
||||
:class="[
|
||||
'absolute hidden rounded bg-old-neutral-700 text-white text-sm group-hover:block z-10',
|
||||
position.top ? 'top-full' : 'bottom-full',
|
||||
position.left ? 'left-0' : 'right-0'
|
||||
]"
|
||||
>
|
||||
<slot name="hoverContent"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import Highcharts from 'highcharts';
|
||||
import { DataAnomaly } from '~/types/ArticleMetaData';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import useColorModeStore from '~/stores/colorModeStore';
|
||||
import useIconStore from '~/stores/iconStore';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||
import type { OverflowBehavior, ScrollbarsAutoHideBehavior, ScrollbarsVisibilityBehavior } from 'overlayscrollbars';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
techStack?: string[] | DataAnomaly;
|
||||
techStackPercent?: number[] | DataAnomaly;
|
||||
techStackIconNames?: string[] | DataAnomaly;
|
||||
techStackThemeColors?: string[] | DataAnomaly;
|
||||
asyncKey: string;
|
||||
}>(), {
|
||||
techStack: () => ['Vue', 'Nuxt', 'TypeScript', 'JavaScript', 'CSS', 'HTML', 'Node.js', 'Python', 'Java', 'C#'],
|
||||
techStackPercent: () => [5, 3, 4, 2, 1, 1, 2, 3, 2, 1],
|
||||
techStackIconNames: () => ['mdi:vuejs', 'lineicons:nuxt', 'mdi:language-typescript', 'mdi:language-javascript', 'material-symbols:css', 'mdi:language-html5', 'mdi:nodejs', 'mdi:language-python', 'mdi:language-java', 'mdi:language-csharp'],
|
||||
techStackThemeColors: () => ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#FFB347', '#98D8C8', '#F7DC6F', '#BB8FCE'],
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const techStackLightIconSVG = ref<string[]>([]);
|
||||
const techStackDarkIconSVG = ref<string[]>([]);
|
||||
const { colorMode } = storeToRefs(useColorModeStore());
|
||||
|
||||
const { pending } = useAsyncData(props.asyncKey, async () => {
|
||||
if (props.techStackIconNames === DataAnomaly.DataNotFound || props.techStackIconNames === DataAnomaly.Invalid)
|
||||
return [];
|
||||
const iconStore = useIconStore();
|
||||
for (const iconName of props.techStackIconNames) {
|
||||
await iconStore.setIconInfo(iconName);
|
||||
}
|
||||
for (const iconName of props.techStackIconNames) {
|
||||
techStackLightIconSVG.value.push(iconStore.getColoredIcon(iconName, '#666'));
|
||||
techStackDarkIconSVG.value.push(iconStore.getColoredIcon(iconName, '#aaa'));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
function toPercent(num: number | undefined): string {
|
||||
if (num === undefined || isNaN(num)) return '0%';
|
||||
num *= 100;
|
||||
const rounded = Math.round(num * 100) / 100;
|
||||
return (rounded === 0 ? '0' : rounded.toFixed(2).replace(/\.?0+$/, '')) + '%';
|
||||
}
|
||||
|
||||
const noDataAvailable = ref(false);
|
||||
const renderChart = () => {
|
||||
const techStack = props.techStack as string[];
|
||||
const techStackPercent = props.techStackPercent as number[];
|
||||
if (!chartRef.value) return;
|
||||
const sum = techStackPercent.reduce((acc, val) => acc + val, 0);
|
||||
const fullArr: [string, number, string, string, string][] = techStack.map((name, index) => [name, techStackPercent[index] / sum, techStackLightIconSVG.value[index] || '', techStackDarkIconSVG.value[index] || '', props.techStackThemeColors[index]] as [string, number, string, string, string]).sort((a, b) => b[1] - a[1]);
|
||||
const dataArr: [string, number][] = fullArr.map((x) => [x[0], x[1]]);
|
||||
const barHeight = 20;
|
||||
const gap = 10;
|
||||
const axisColor = '#aaa';
|
||||
chartRef.value.style.height = `${dataArr.length * (barHeight + gap) + 25}px`;
|
||||
Highcharts.chart(chartRef.value, {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: undefined,
|
||||
},
|
||||
legend: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories: dataArr.map(([name]) => name),
|
||||
title: {
|
||||
text: undefined,
|
||||
},
|
||||
labels: {
|
||||
useHTML: true,
|
||||
formatter: function () {
|
||||
return `<div style="width: 25px; height: 25px;" title="${fullArr[this.pos][0]}">
|
||||
${colorMode.value === 'light' ? fullArr[this.pos][2] : fullArr[this.pos][3]}
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
lineColor: axisColor, // 设置 x 轴线颜色
|
||||
tickColor: axisColor, // 设置 x 轴刻度颜色
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: {
|
||||
text: undefined, // 修复 null 类型错误
|
||||
},
|
||||
labels: {
|
||||
enabled: false, // 关闭 y 轴标签显示
|
||||
},
|
||||
gridLineWidth: 0, // 关闭网格线
|
||||
lineWidth: 0, // 关闭 y 轴线
|
||||
tickWidth: 0, // 关闭刻度线
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Tech Stack',
|
||||
data: dataArr.map(([, value]) => value),
|
||||
type: 'bar',
|
||||
colorByPoint: true,
|
||||
colors: fullArr.map((x) => x[4]),
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
formatter: function () {
|
||||
return `${fullArr[this.x][0]} ${toPercent(this.y)}`;
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
borderRadius: 5,
|
||||
pointWidth: 20,
|
||||
groupPadding: 0.1,
|
||||
pointPadding: 0.05,
|
||||
borderWidth: 0,
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
style: {
|
||||
color: '#fff',
|
||||
},
|
||||
formatter: function () {
|
||||
return toPercent(this.y); // 自定义条形图值的显示格式
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
});
|
||||
};
|
||||
|
||||
let colorModeCallBackKey = '';
|
||||
onMounted(() => {
|
||||
if (props.techStack === DataAnomaly.DataNotFound || props.techStack === DataAnomaly.Invalid
|
||||
|| props.techStackPercent === DataAnomaly.DataNotFound || props.techStackPercent === DataAnomaly.Invalid
|
||||
|| props.techStackThemeColors === DataAnomaly.DataNotFound || props.techStackThemeColors === DataAnomaly.Invalid
|
||||
|| props.techStack.length === 0 || props.techStackPercent.length === 0 || props.techStackThemeColors.length === 0) {
|
||||
noDataAvailable.value = true;
|
||||
return;
|
||||
}
|
||||
window.addEventListener('resize', renderChart);
|
||||
colorModeCallBackKey = useColorModeStore().registerCallBack(renderChart);
|
||||
watch(pending, (isPending) => {
|
||||
if (!isPending) {
|
||||
// 数据加载完成后的回调逻辑
|
||||
renderChart();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', renderChart);
|
||||
useColorModeStore().unregisterCallBack(colorModeCallBackKey);
|
||||
});
|
||||
|
||||
const scrollbarOptions = {
|
||||
scrollbars: {
|
||||
theme: 'os-theme-thin-dark',
|
||||
visibility: 'auto' as ScrollbarsVisibilityBehavior,
|
||||
autoHide: 'move' as ScrollbarsAutoHideBehavior,
|
||||
autoHideDelay: 1300,
|
||||
dragScroll: true,
|
||||
clickScroll: true,
|
||||
},
|
||||
overflow: {
|
||||
x: 'hidden' as OverflowBehavior,
|
||||
y: 'scroll' as OverflowBehavior,
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div v-if="noDataAvailable" class="flex items-center justify-center h-full p-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 112.01">
|
||||
<g id="_图层_1" data-name="图层 1">
|
||||
<polyline class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]" points="1.5 28.63 1.5 1.5 63.5 1.59"/>
|
||||
<polyline class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]" points="254.5 83.38 254.5 110.5 192.5 110.41"/>
|
||||
<polyline class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10] stroke-3" points="254.5 28.63 254.5 1.5 192.5 1.59"/>
|
||||
<polyline class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10] stroke-3" points="1.5 83.38 1.5 110.5 63.5 110.41"/>
|
||||
</g>
|
||||
<g id="_图层_2" data-name="图层 2">
|
||||
<text class="[font-family:ArialMT,Arial] text-xl stroke-old-neutral-600 dark:stroke-old-neutral-400 fill-old-neutral-600 dark:fill-old-neutral-400" transform="translate(89.48 61.61)"><tspan x="0" y="0">No Data</tspan></text>
|
||||
</g>
|
||||
<g id="_图层_3" data-name="图层 3">
|
||||
<line class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]" x1="16" y1="11" x2="90" y2="39.8"/>
|
||||
<line class="fill-none stroke-old-neutral-400 dark:stroke-old-neutral-500 [stroke-miterlimit:10]" x1="166" y1="72.21" x2="240" y2="101"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<overlay-scrollbars-component v-else class="max-h-full" :options="scrollbarOptions">
|
||||
<div ref="chartRef" class="w-full"/>
|
||||
</overlay-scrollbars-component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
:deep(.os-scrollbar) {
|
||||
--os-size: 4px;
|
||||
--os-padding-perpendicular: 0px;
|
||||
--os-padding-axis: 0px;
|
||||
--os-track-border-radius: 2px;
|
||||
--os-handle-border-radius: 2px;
|
||||
--os-handle-bg: rgba(156, 163, 175, 0.5);
|
||||
--os-handle-bg-hover: rgba(156, 163, 175, 0.8);
|
||||
--os-handle-bg-active: rgba(107, 114, 128, 1);
|
||||
}
|
||||
|
||||
/* 暗色模式 */
|
||||
.dark :deep(.os-scrollbar) {
|
||||
--os-handle-bg: rgba(75, 85, 99, 0.5);
|
||||
--os-handle-bg-hover: rgba(75, 85, 99, 0.8);
|
||||
--os-handle-bg-active: rgba(107, 114, 128, 1);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user