mirror of
https://github.com/RhenCloud/Cloud-Home.git
synced 2026-01-22 17:39:07 +08:00
feat: 添加 Wakatime 支持,更新统计组件以显示编码数据
This commit is contained in:
@@ -99,6 +99,11 @@ const siteConfig: SiteConfig = {
|
|||||||
apiBase: "https://api.umami.is", // Umami API 地址,一般无需修改
|
apiBase: "https://api.umami.is", // Umami API 地址,一般无需修改
|
||||||
},
|
},
|
||||||
|
|
||||||
|
wakatime: {
|
||||||
|
enable: true, // 是否启用 Wakatime 统计
|
||||||
|
apiUrl: "https://wakatime.com/api/v1", // Wakatime API 地址,默认官方地址
|
||||||
|
},
|
||||||
|
|
||||||
// 技能图标展示,详见https://github.com/tandpfun/skill-icons#icons-list
|
// 技能图标展示,详见https://github.com/tandpfun/skill-icons#icons-list
|
||||||
skills: [
|
skills: [
|
||||||
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
|
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
|
||||||
@@ -163,6 +168,7 @@ const siteConfig: SiteConfig = {
|
|||||||
|
|
||||||
- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选)
|
- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选)
|
||||||
- `UMAMI_API_KEY`: Umami 分析的 API Key。
|
- `UMAMI_API_KEY`: Umami 分析的 API Key。
|
||||||
|
- `WAKATIME_API_KEY`: Wakatime API Key,用于获取编码统计数据。
|
||||||
- `SMTP_HOST`: 邮件服务器主机名
|
- `SMTP_HOST`: 邮件服务器主机名
|
||||||
- `SMTP_PORT`: 端口(如 465 或 587)
|
- `SMTP_PORT`: 端口(如 465 或 587)
|
||||||
- `SMTP_USER`: 发件人邮箱账号
|
- `SMTP_USER`: 发件人邮箱账号
|
||||||
|
|||||||
71
api/wakatime.ts
Normal file
71
api/wakatime.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// CORS 处理
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ error: "Method Not Allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.WAKATIME_API_KEY;
|
||||||
|
const defaultApiUrl = process.env.WAKATIME_API_URL || "https://wakatime.com/api/v1";
|
||||||
|
const apiUrl = (req.query.apiUrl as string) || defaultApiUrl;
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({ error: "Wakatime API Key not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取一周统计数据
|
||||||
|
const weeklyStatsResponse = await fetch(`${apiUrl}/users/current/stats/last_7_days`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!weeklyStatsResponse.ok) {
|
||||||
|
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeklyStatsData = await weeklyStatsResponse.json();
|
||||||
|
|
||||||
|
// 获取所有时间统计数据
|
||||||
|
const allTimeStatsResponse = await fetch(`${apiUrl}/users/current/stats/all_time`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let allTimeStatsData = null;
|
||||||
|
if (allTimeStatsResponse.ok) {
|
||||||
|
allTimeStatsData = await allTimeStatsResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户状态
|
||||||
|
const statusResponse = await fetch(`${apiUrl}/users/current/status`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let statusData = null;
|
||||||
|
if (statusResponse.ok) {
|
||||||
|
statusData = await statusResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
weekly: weeklyStatsData.data,
|
||||||
|
allTime: allTimeStatsData ? allTimeStatsData.data : null,
|
||||||
|
status: statusData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Wakatime API error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch Wakatime data" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>GitHub</h2>
|
<h2>GitHub</h2>
|
||||||
|
<div class="heatmap">
|
||||||
|
<h3>提交热力图</h3>
|
||||||
|
<p class="muted">我的提交热力图 · Acitivity Heatmap</p>
|
||||||
|
<img :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" />
|
||||||
|
</div>
|
||||||
<div class="lang-wrap">
|
<div class="lang-wrap">
|
||||||
<h3>常用语言</h3>
|
<h3>常用语言</h3>
|
||||||
<p class="muted">我常用的语言 · Languages</p>
|
<p class="muted">我常用的语言 · Languages</p>
|
||||||
@@ -19,11 +24,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="heatmap">
|
|
||||||
<h3>提交热力图</h3>
|
|
||||||
<p class="muted">我的提交热力图 · Acitivity Heatmap</p>
|
|
||||||
<img :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
432
src/components/StatsSection.vue
Normal file
432
src/components/StatsSection.vue
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h2>开发统计</h2>
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-button" :class="{ active: activeTab === 'github' }" @click="activeTab = 'github'">
|
||||||
|
GitHub
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'wakatime' }"
|
||||||
|
@click="activeTab = 'wakatime'"
|
||||||
|
>
|
||||||
|
Wakatime
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GitHub 内容 -->
|
||||||
|
<div v-if="activeTab === 'github'">
|
||||||
|
<div class="heatmap">
|
||||||
|
<h3>提交热力图</h3>
|
||||||
|
<p class="muted">我的提交热力图 · Activity Heatmap</p>
|
||||||
|
<img :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="lang-wrap">
|
||||||
|
<h3>常用语言</h3>
|
||||||
|
<p class="muted">我常用的语言 · Languages</p>
|
||||||
|
<div class="lang-chart">
|
||||||
|
<ul class="list lang-list">
|
||||||
|
<li v-for="lang in githubLanguages" :key="lang.name" class="lang-row">
|
||||||
|
<div class="lang-label">
|
||||||
|
<span class="dot" :style="{ background: colorFor(lang.name, 'github') }"></span>
|
||||||
|
<span class="lang-name">{{ lang.name }}</span>
|
||||||
|
<span class="lang-percent">{{ lang.percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lang-bar">
|
||||||
|
<span class="lang-bar-fill" :style="barStyle(lang, 'github')"></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wakatime 内容 -->
|
||||||
|
<div v-if="activeTab === 'wakatime'">
|
||||||
|
<div class="stats-wrap">
|
||||||
|
<h3>编码统计</h3>
|
||||||
|
<p class="muted">
|
||||||
|
{{ wakatimeActiveTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }}
|
||||||
|
</p>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{
|
||||||
|
currentWakatimeData.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A"
|
||||||
|
}}</span>
|
||||||
|
<span class="stat-label">总时间</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{
|
||||||
|
currentWakatimeData.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A"
|
||||||
|
}}</span>
|
||||||
|
<span class="stat-label">日均</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{ currentWakatimeData.days_including_holidays || "N/A" }}</span>
|
||||||
|
<span class="stat-label">活跃天数</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lang-wrap" v-if="currentWakatimeData.languages && currentWakatimeData.languages.length">
|
||||||
|
<h3>编程语言</h3>
|
||||||
|
<p class="muted">语言使用统计 · Languages</p>
|
||||||
|
<div class="lang-chart">
|
||||||
|
<ul class="list lang-list">
|
||||||
|
<li v-for="lang in wakatimeLanguages" :key="lang.name" class="lang-row">
|
||||||
|
<div class="lang-label">
|
||||||
|
<span class="dot" :style="{ background: colorFor(lang.name, 'wakatime') }"></span>
|
||||||
|
<span class="lang-name">{{ lang.name }}</span>
|
||||||
|
<span class="lang-percent">{{ lang.percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lang-bar">
|
||||||
|
<span class="lang-bar-fill" :style="barStyle(lang, 'wakatime')"></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wakatime-tabs" v-if="allTimeData">
|
||||||
|
<div class="wakatime-mini-tabs">
|
||||||
|
<button
|
||||||
|
class="wakatime-tab-button"
|
||||||
|
:class="{ active: wakatimeActiveTab === 'weekly' }"
|
||||||
|
@click="wakatimeActiveTab = 'weekly'"
|
||||||
|
>
|
||||||
|
最近7天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="wakatime-tab-button"
|
||||||
|
:class="{ active: wakatimeActiveTab === 'allTime' }"
|
||||||
|
@click="wakatimeActiveTab = 'allTime'"
|
||||||
|
>
|
||||||
|
所有时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-wrap" v-if="statusData">
|
||||||
|
<h3>当前状态</h3>
|
||||||
|
<p class="muted">实时状态 · Current Status</p>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-indicator" :class="{ active: statusData.is_coding }"></span>
|
||||||
|
<span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span>
|
||||||
|
<span class="status-project" v-if="statusData.project">{{ statusData.project }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, reactive, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({ github: Object, wakatime: Object });
|
||||||
|
const github = props.github;
|
||||||
|
const wakatime = props.wakatime;
|
||||||
|
|
||||||
|
const activeTab = ref("github");
|
||||||
|
const wakatimeActiveTab = ref("weekly");
|
||||||
|
|
||||||
|
const weeklyData = ref(null);
|
||||||
|
const allTimeData = ref(null);
|
||||||
|
const statusData = ref(null);
|
||||||
|
const showComponent = ref(true);
|
||||||
|
|
||||||
|
const githubPalette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
|
||||||
|
const wakatimePalette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
|
||||||
|
|
||||||
|
const githubLanguages = computed(() => (Array.isArray(github.languages) ? github.languages.slice(0, 5) : []));
|
||||||
|
|
||||||
|
const currentWakatimeData = computed(() => {
|
||||||
|
return wakatimeActiveTab.value === "weekly" ? weeklyData.value : allTimeData.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wakatimeLanguages = computed(() => {
|
||||||
|
if (!currentWakatimeData.value || !currentWakatimeData.value.languages) return [];
|
||||||
|
return currentWakatimeData.value.languages.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorFor = (name, type) => {
|
||||||
|
const palette = type === "github" ? githubPalette : wakatimePalette;
|
||||||
|
const languages = type === "github" ? github.languages : currentWakatimeData.value?.languages || [];
|
||||||
|
const idx = languages.findIndex((l) => l.name === name);
|
||||||
|
return palette[(idx >= 0 ? idx : 0) % palette.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const barStyle = (lang, type) => ({
|
||||||
|
width: `${Math.max(8, lang.percent)}%`,
|
||||||
|
background: colorFor(lang.name, type),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatTime = (seconds) => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWakatimeData = async () => {
|
||||||
|
if (!wakatime.enable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (wakatime.apiUrl && wakatime.apiUrl !== "https://wakatime.com/api/v1") {
|
||||||
|
params.append("apiUrl", wakatime.apiUrl);
|
||||||
|
}
|
||||||
|
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
|
||||||
|
console.log("Fetching Wakatime data from:", url);
|
||||||
|
const response = await fetch(url);
|
||||||
|
console.log("Response status:", response.status);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("Wakatime data:", data);
|
||||||
|
weeklyData.value = data.weekly;
|
||||||
|
allTimeData.value = data.allTime;
|
||||||
|
statusData.value = data.status;
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("API Error:", response.status, errorText);
|
||||||
|
if (response.status === 500 && errorText.includes("Wakatime API Key not configured")) {
|
||||||
|
console.warn("Wakatime API Key not configured - hiding component");
|
||||||
|
showComponent.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`API returned ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch Wakatime data:", error);
|
||||||
|
// 在开发环境中,如果 API 不可用,设置一些示例数据
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("Using mock data for development");
|
||||||
|
weeklyData.value = {
|
||||||
|
total_seconds: 36000,
|
||||||
|
daily_average: 5142,
|
||||||
|
days_including_holidays: 7,
|
||||||
|
languages: [
|
||||||
|
{ name: "TypeScript", percent: 45.2, total_seconds: 16272 },
|
||||||
|
{ name: "Vue", percent: 30.1, total_seconds: 10836 },
|
||||||
|
{ name: "JavaScript", percent: 15.3, total_seconds: 5508 },
|
||||||
|
{ name: "Python", percent: 9.4, total_seconds: 3384 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
allTimeData.value = {
|
||||||
|
total_seconds: 864000,
|
||||||
|
daily_average: 2800,
|
||||||
|
days_including_holidays: 308,
|
||||||
|
languages: [
|
||||||
|
{ name: "JavaScript", percent: 35.2, total_seconds: 304128 },
|
||||||
|
{ name: "TypeScript", percent: 28.1, total_seconds: 242688 },
|
||||||
|
{ name: "Python", percent: 20.3, total_seconds: 175392 },
|
||||||
|
{ name: "Vue", percent: 10.1, total_seconds: 87296 },
|
||||||
|
{ name: "CSS", percent: 6.3, total_seconds: 54432 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
statusData.value = { is_coding: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchWakatimeData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #e8eefc;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
background: #7cc1ff;
|
||||||
|
color: white;
|
||||||
|
border-color: #7cc1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e8eefc;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #a8b3cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wakatime-tabs {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wakatime-mini-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wakatime-tab-button {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #e8eefc;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wakatime-tab-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wakatime-tab-button.active {
|
||||||
|
background: #6bdba6;
|
||||||
|
color: white;
|
||||||
|
border-color: #6bdba6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #a8b3cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.active {
|
||||||
|
background: #6bdba6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-project {
|
||||||
|
color: #a8b3cf;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-wrap {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-chart {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-row {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-name {
|
||||||
|
color: #e8eefc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-percent {
|
||||||
|
color: #a8b3cf;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-bar {
|
||||||
|
margin-top: 6px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
333
src/components/WakatimeSection.vue
Normal file
333
src/components/WakatimeSection.vue
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card" v-if="showComponent && (weeklyData || allTimeData)">
|
||||||
|
<div class="header">
|
||||||
|
<h2>Wakatime</h2>
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-button" :class="{ active: activeTab === 'weekly' }" @click="activeTab = 'weekly'">
|
||||||
|
最近7天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'allTime' }"
|
||||||
|
@click="activeTab = 'allTime'"
|
||||||
|
v-if="allTimeData"
|
||||||
|
>
|
||||||
|
所有时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-wrap">
|
||||||
|
<h3>编码统计</h3>
|
||||||
|
<p class="muted">{{ activeTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }}</p>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{
|
||||||
|
currentData.total_seconds ? formatTime(currentData.total_seconds) : "N/A"
|
||||||
|
}}</span>
|
||||||
|
<span class="stat-label">总时间</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{
|
||||||
|
currentData.daily_average ? formatTime(currentData.daily_average) : "N/A"
|
||||||
|
}}</span>
|
||||||
|
<span class="stat-label">日均</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{ currentData.days_including_holidays || "N/A" }}</span>
|
||||||
|
<span class="stat-label">活跃天数</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lang-wrap" v-if="currentData.languages && currentData.languages.length">
|
||||||
|
<h3>编程语言</h3>
|
||||||
|
<p class="muted">语言使用统计 · Languages</p>
|
||||||
|
<div class="lang-chart">
|
||||||
|
<ul class="list lang-list">
|
||||||
|
<li v-for="lang in topLanguages" :key="lang.name" class="lang-row">
|
||||||
|
<div class="lang-label">
|
||||||
|
<span class="dot" :style="{ background: colorFor(lang.name) }"></span>
|
||||||
|
<span class="lang-name">{{ lang.name }}</span>
|
||||||
|
<span class="lang-percent">{{ lang.percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lang-bar">
|
||||||
|
<span class="lang-bar-fill" :style="barStyle(lang)"></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-wrap" v-if="statusData">
|
||||||
|
<h3>当前状态</h3>
|
||||||
|
<p class="muted">实时状态 · Current Status</p>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-indicator" :class="{ active: statusData.is_coding }"></span>
|
||||||
|
<span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span>
|
||||||
|
<span class="status-project" v-if="statusData.project">{{ statusData.project }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({ wakatime: Object });
|
||||||
|
const wakatime = props.wakatime;
|
||||||
|
|
||||||
|
const weeklyData = ref(null);
|
||||||
|
const allTimeData = ref(null);
|
||||||
|
const statusData = ref(null);
|
||||||
|
const showComponent = ref(true);
|
||||||
|
const activeTab = ref("weekly");
|
||||||
|
|
||||||
|
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
|
||||||
|
|
||||||
|
const currentData = computed(() => {
|
||||||
|
return activeTab.value === "weekly" ? weeklyData.value : allTimeData.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const topLanguages = computed(() => {
|
||||||
|
if (!currentData.value || !currentData.value.languages) return [];
|
||||||
|
return currentData.value.languages.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorFor = (name) => {
|
||||||
|
const idx = currentData.value.languages.findIndex((l) => l.name === name);
|
||||||
|
return palette[(idx >= 0 ? idx : 0) % palette.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const barStyle = (lang) => ({
|
||||||
|
width: `${Math.max(8, lang.percent)}%`,
|
||||||
|
background: colorFor(lang.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatTime = (seconds) => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWakatimeData = async () => {
|
||||||
|
if (!wakatime.enable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (wakatime.apiUrl && wakatime.apiUrl !== "https://wakatime.com/api/v1") {
|
||||||
|
params.append("apiUrl", wakatime.apiUrl);
|
||||||
|
}
|
||||||
|
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
|
||||||
|
console.log("Fetching Wakatime data from:", url);
|
||||||
|
const response = await fetch(url);
|
||||||
|
console.log("Response status:", response.status);
|
||||||
|
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("Wakatime data:", data);
|
||||||
|
weeklyData.value = data.weekly;
|
||||||
|
allTimeData.value = data.allTime;
|
||||||
|
statusData.value = data.status;
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("API Error:", response.status, errorText);
|
||||||
|
if (response.status === 500 && errorText.includes("Wakatime API Key not configured")) {
|
||||||
|
console.warn("Wakatime API Key not configured - hiding component");
|
||||||
|
showComponent.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`API returned ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch Wakatime data:", error);
|
||||||
|
// 在开发环境中,如果 API 不可用,设置一些示例数据
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("Using mock data for development");
|
||||||
|
weeklyData.value = {
|
||||||
|
total_seconds: 36000,
|
||||||
|
daily_average: 5142,
|
||||||
|
days_including_holidays: 7,
|
||||||
|
languages: [
|
||||||
|
{ name: "TypeScript", percent: 45.2, total_seconds: 16272 },
|
||||||
|
{ name: "Vue", percent: 30.1, total_seconds: 10836 },
|
||||||
|
{ name: "JavaScript", percent: 15.3, total_seconds: 5508 },
|
||||||
|
{ name: "Python", percent: 9.4, total_seconds: 3384 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
allTimeData.value = {
|
||||||
|
total_seconds: 864000,
|
||||||
|
daily_average: 2800,
|
||||||
|
days_including_holidays: 308,
|
||||||
|
languages: [
|
||||||
|
{ name: "JavaScript", percent: 35.2, total_seconds: 304128 },
|
||||||
|
{ name: "TypeScript", percent: 28.1, total_seconds: 242688 },
|
||||||
|
{ name: "Python", percent: 20.3, total_seconds: 175392 },
|
||||||
|
{ name: "Vue", percent: 10.1, total_seconds: 87296 },
|
||||||
|
{ name: "CSS", percent: 6.3, total_seconds: 54432 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
statusData.value = { is_coding: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchWakatimeData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.active {
|
||||||
|
background: #6bdba6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-project {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-wrap {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-chart {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-row {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-name {
|
||||||
|
color: #e8eefc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-percent {
|
||||||
|
color: #a8b3cf;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-bar {
|
||||||
|
margin-top: 6px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,6 +74,11 @@ const siteConfig = {
|
|||||||
apiBase: "https://api.umami.is",
|
apiBase: "https://api.umami.is",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
wakatime: {
|
||||||
|
enable: true,
|
||||||
|
apiUrl: "https://wakapi.rhen.cloud/api/v1",
|
||||||
|
},
|
||||||
|
|
||||||
skills: [
|
skills: [
|
||||||
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
|
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
|
||||||
{ title: "后端 / 云", items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"] },
|
{ title: "后端 / 云", items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"] },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<main class="page">
|
<main class="page">
|
||||||
<HeroSection :profile="profile" />
|
<HeroSection :profile="profile" />
|
||||||
<SkillsSection :skills="skills" />
|
<SkillsSection :skills="skills" />
|
||||||
<GithubSection :github="github" />
|
<StatsSection :github="github" :wakatime="wakatime" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -10,12 +10,13 @@
|
|||||||
import { onMounted, reactive } from "vue";
|
import { onMounted, reactive } from "vue";
|
||||||
import HeroSection from "../components/HeroSection.vue";
|
import HeroSection from "../components/HeroSection.vue";
|
||||||
import SkillsSection from "../components/SkillsSection.vue";
|
import SkillsSection from "../components/SkillsSection.vue";
|
||||||
import GithubSection from "../components/GithubSection.vue";
|
import StatsSection from "../components/StatsSection.vue";
|
||||||
import siteConfig from "../config/siteConfig";
|
import siteConfig from "../config/siteConfig";
|
||||||
|
|
||||||
const profile = siteConfig.profile;
|
const profile = siteConfig.profile;
|
||||||
const siteMeta = siteConfig.siteMeta;
|
const siteMeta = siteConfig.siteMeta;
|
||||||
const skills = siteConfig.skills;
|
const skills = siteConfig.skills;
|
||||||
|
const wakatime = siteConfig.wakatime;
|
||||||
|
|
||||||
const github = reactive({
|
const github = reactive({
|
||||||
...siteConfig.github,
|
...siteConfig.github,
|
||||||
@@ -51,7 +52,7 @@ async function fetchGithubMeta() {
|
|||||||
const parsed = Object.entries(counts)
|
const parsed = Object.entries(counts)
|
||||||
.map(([name, count]) => ({ name, count }))
|
.map(([name, count]) => ({ name, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 4);
|
.slice(0, 5);
|
||||||
github.languages = parsed.map((item) => ({
|
github.languages = parsed.map((item) => ({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
percent: Math.round((item.count / total) * 100),
|
percent: Math.round((item.count / total) * 100),
|
||||||
|
|||||||
Reference in New Issue
Block a user