mirror of
https://github.com/RhenCloud/Cloud-Home.git
synced 2026-01-22 17:39:07 +08:00
refactor:删除已弃用的组件和样式,迁移到 Tailwind CSS
- 改进项目页面、网站页面、友链页面 - 从 styles.css 中移除全局样式。 - 添加 tailwind.config.ts 以配置 Tailwind CSS。 - 更新 tsconfig.json,加入 Vue 组件的新路径映射。
This commit is contained in:
84
app/components/AboutSection.vue
Normal file
84
app/components/AboutSection.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1">个人简介</h2>
|
||||
<p class="text-text-muted text-sm m-0 mb-3 block">关于我 · About Me</p>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-3.5">
|
||||
<article v-if="age"
|
||||
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-primary/6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl leading-none">🎂</span>
|
||||
<h3 class="m-0 text-sm font-semibold text-white/90">年龄</h3>
|
||||
</div>
|
||||
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60">
|
||||
{{ age }} 岁
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article v-if="profile?.gender"
|
||||
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl leading-none">⚧️</span>
|
||||
<h3 class="m-0 text-sm font-semibold text-white/90">性别</h3>
|
||||
</div>
|
||||
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60">
|
||||
{{ profile.gender }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article v-if="profile?.pronouns"
|
||||
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl leading-none">🗣️</span>
|
||||
<h3 class="m-0 text-sm font-semibold text-white/90">代词</h3>
|
||||
</div>
|
||||
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60">
|
||||
{{ profile.pronouns }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article v-if="profile?.location"
|
||||
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl leading-none">📍</span>
|
||||
<h3 class="m-0 text-sm font-semibold text-white/90">地区</h3>
|
||||
</div>
|
||||
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60">
|
||||
{{ profile.location }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5 mt-2.5">
|
||||
<article v-for="item in items" :key="item.title"
|
||||
class="bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-3 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="text-2xl leading-none">{{ item.icon }}</span>
|
||||
<h3 class="m-0 text-base font-semibold">{{ item.title }}</h3>
|
||||
</div>
|
||||
<p class="text-text-muted text-sm m-0">{{ item.desc }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
items: Array,
|
||||
profile: Object,
|
||||
});
|
||||
|
||||
const age = computed(() => {
|
||||
if (!props.profile?.birthday) return null;
|
||||
const birthDate = new Date(props.profile.birthday);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const m = today.getMonth() - birthDate.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
});
|
||||
</script>
|
||||
125
app/components/FooterSection.vue
Normal file
125
app/components/FooterSection.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<footer class="card text-center mt-auto w-full flex flex-col gap-1">
|
||||
<!-- 一言 -->
|
||||
<p class="text-text-muted text-sm m-0 italic" v-if="showHitokoto && quote">
|
||||
「{{ quote }}」<span v-if="from" class="ml-1.5">—— {{ from }}</span>
|
||||
</p>
|
||||
|
||||
<!-- 访问统计 -->
|
||||
<p class="text-text-muted text-xs m-0" v-if="showStats && !statsError">
|
||||
👁️ {{ visitors }} · 📊 {{ pageviews }}
|
||||
</p>
|
||||
|
||||
<!-- 备案信息 -->
|
||||
<p class="text-text-muted text-xs m-0" v-if="contact.beian">
|
||||
<a :href="contact.beianLink || 'https://beian.miit.gov.cn/'" target="_blank" rel="noreferrer"
|
||||
class="opacity-85 transition-all duration-200 hover:text-primary hover:opacity-100">
|
||||
{{ contact.beian }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- 框架与技术栈信息 -->
|
||||
<p class="text-text-muted text-xs m-0">
|
||||
Powered by
|
||||
<a href="https://nuxt.com" target="_blank" rel="noreferrer"
|
||||
class="text-primary hover:text-accent transition-colors">Nuxt 4</a>
|
||||
·
|
||||
<a href="https://tailwindcss.com" target="_blank" rel="noreferrer"
|
||||
class="text-primary hover:text-accent transition-colors">Tailwind CSS</a>
|
||||
·
|
||||
<a href="https://vuejs.org" target="_blank" rel="noreferrer"
|
||||
class="text-primary hover:text-accent transition-colors">Vue 3</a>
|
||||
</p>
|
||||
|
||||
<!-- 自定义 HTML -->
|
||||
<div v-if="contact.customHtml" v-html="contact.customHtml"></div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
const props = defineProps({ contact: Object });
|
||||
const config = useRuntimeConfig();
|
||||
const quote = ref("");
|
||||
const from = ref("");
|
||||
const pageviews = ref(0);
|
||||
const visitors = ref(0);
|
||||
const statsError = ref(true);
|
||||
const showHitokoto = siteConfig.footer?.hitokoto?.enable;
|
||||
const showStats = ref(siteConfig.umami?.enable);
|
||||
|
||||
const buildHitokotoUrl = () => {
|
||||
const type = siteConfig.footer?.hitokoto?.type;
|
||||
const url = new URL("https://v1.hitokoto.cn/");
|
||||
if (Array.isArray(type)) {
|
||||
type.filter(Boolean).forEach((t) => url.searchParams.append("c", t));
|
||||
} else if (typeof type === "string") {
|
||||
type.split("&")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((t) => url.searchParams.append("c", t));
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const fetchHitokoto = async () => {
|
||||
try {
|
||||
const resp = await fetch(buildHitokotoUrl());
|
||||
const data = await resp.json();
|
||||
quote.value = data.hitokoto || "";
|
||||
from.value = data.from || "";
|
||||
} catch (e) {
|
||||
console.warn("Hitokoto fetch failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
if (!siteConfig.umami?.apiBase || !siteConfig.umami?.websiteId) {
|
||||
return;
|
||||
}
|
||||
const apiBase = siteConfig.umami.apiBase;
|
||||
const websiteId = siteConfig.umami.websiteId;
|
||||
const apiKey = config.public.umamiApiKey;
|
||||
|
||||
if (!apiKey) return;
|
||||
|
||||
// 获取统计数据
|
||||
const endAt = Date.now();
|
||||
const startAt = new Date(siteConfig.siteMeta.startDate).getTime();
|
||||
|
||||
const resp = await fetch(`${apiBase}/v1/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.warn(`Stats API returned ${resp.status}`);
|
||||
statsError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (data) {
|
||||
statsError.value = false;
|
||||
pageviews.value = data.pageviews;
|
||||
visitors.value = data.visitors;
|
||||
}
|
||||
|
||||
if (pageviews.value === 0 && visitors.value === 0) {
|
||||
showStats.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
statsError.value = true;
|
||||
console.debug("Stats fetch failed (this is normal if blocked by ad blocker):", e.message);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (showHitokoto) fetchHitokoto();
|
||||
if (showStats.value) fetchStats();
|
||||
});
|
||||
</script>
|
||||
225
app/components/FriendsSection.vue
Normal file
225
app/components/FriendsSection.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="card panel flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1 gradient-text">友情链接</h2>
|
||||
<p class="text-text-muted text-sm m-0 mb-3 block">欢迎互换友链 · Friends</p>
|
||||
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
|
||||
<article v-for="f in displayedFriends" :key="f.url"
|
||||
class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-pink-400/50 w-[290px] h-[145px] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<img v-if="f.avatar" :src="f.avatar" :alt="f.name" loading="lazy"
|
||||
class="w-12 h-12 rounded-full object-cover border border-white/15" />
|
||||
<h3 class="m-0 font-semibold text-base whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ f.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="rounded-full px-2.5 py-1 text-xs bg-purple-400/15 text-purple-300">友链</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
|
||||
{{ f.desc || "一个有趣的站点" }}
|
||||
</p>
|
||||
|
||||
<a :href="f.url" target="_blank" rel="noreferrer"
|
||||
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-pink-300 hover:text-pink-400 transition-all duration-200 hover:gap-2">
|
||||
访问 →
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<section class="card flex flex-col gap-2.5">
|
||||
<div class="flex justify-center items-center align-center flex-wrap">
|
||||
<button @click="openForm"
|
||||
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/80 hover:shadow-lg hover:shadow-primary/25">
|
||||
申请友链
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<Teleport to="body">
|
||||
<div v-if="showDialog" class="fixed inset-0 bg-black/45 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
@click.self="closeDialog">
|
||||
<div
|
||||
class="min-w-[280px] max-w-[420px] bg-gradient-to-br from-pink-500/12 to-white/8 border border-white/15 rounded-2xl p-4 shadow-xl">
|
||||
<h3 class="m-0 mb-2">{{ dialogTitle }}</h3>
|
||||
<p class="text-text-muted text-sm mb-4">{{ dialogText }}</p>
|
||||
<div class="flex justify-end">
|
||||
<button @click="closeDialog"
|
||||
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary cursor-pointer hover:bg-primary/20 transition-all">
|
||||
好的
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 申请友链模态弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showFormModal"
|
||||
class="fixed inset-0 bg-black/45 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
@click.self="showFormModal = false">
|
||||
<div
|
||||
class="w-[92%] max-w-[540px] bg-gradient-to-br from-white/8 to-primary/6 border border-white/15 rounded-2xl p-6 shadow-xl">
|
||||
<h3 class="m-0 mb-4 text-center">申请友链</h3>
|
||||
|
||||
<div class="mb-4 text-sm text-text-primary">
|
||||
<div class="mb-2 font-semibold">请在申请前在你站点添加以下信息(示例 JSON):</div>
|
||||
<pre
|
||||
class="bg-white/6 border border-white/10 rounded-lg p-3 text-xs overflow-auto"><code>{{ exampleJson }}</code></pre>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitForm" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold sm:col-span-2">
|
||||
网站名称 *
|
||||
<input v-model="form.name" required placeholder="网站名称"
|
||||
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" />
|
||||
</label>
|
||||
|
||||
<!-- URL 与 Email 同行 -->
|
||||
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
|
||||
网站链接 *
|
||||
<input v-model="form.url" type="url" required placeholder="https://example.com"
|
||||
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
|
||||
联系邮箱 *
|
||||
<input v-model="form.email" type="email" required placeholder="example@example.com"
|
||||
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" />
|
||||
</label>
|
||||
|
||||
<!-- 描述 与 头像 同行 -->
|
||||
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
|
||||
网站描述
|
||||
<input v-model="form.desc" placeholder="可选"
|
||||
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
|
||||
头像链接
|
||||
<input v-model="form.avatar" type="url" placeholder="可选,展示头像"
|
||||
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold sm:col-span-2">
|
||||
想说的话
|
||||
<div class="flex items-center gap-2">
|
||||
<textarea v-model="form.message" placeholder="可选,最多50字" maxlength="50"
|
||||
class="flex-1 px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary h-24 resize-none"></textarea>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="sm:col-span-2 flex items-center justify-center gap-3 mt-2">
|
||||
<button type="button" @click="showFormModal = false"
|
||||
class="px-3 py-2 rounded-2xl border border-white/10 bg-white/6">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" :disabled="loading"
|
||||
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary disabled:opacity-50">
|
||||
{{ loading ? "提交中..." : "提交" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<span class="text-text-muted text-sm">{{ message }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch, computed } from "vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
const props = defineProps({ friends: { type: Array, default: () => [] } });
|
||||
const showFormModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const showDialog = ref(false);
|
||||
const dialogTitle = ref("");
|
||||
const dialogText = ref("");
|
||||
const form = reactive({
|
||||
name: "",
|
||||
url: "",
|
||||
desc: "",
|
||||
email: "",
|
||||
avatar: "",
|
||||
message: "",
|
||||
});
|
||||
const displayedFriends = ref([]);
|
||||
|
||||
const shuffle = (list) => {
|
||||
const arr = [...list];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.friends,
|
||||
(val) => {
|
||||
displayedFriends.value = shuffle(val || []);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const exampleJson = computed(() => {
|
||||
const name = siteConfig.profile?.name || siteConfig.siteMeta?.title || "";
|
||||
const url = siteConfig.siteMeta?.url || "";
|
||||
const desc = siteConfig.profile?.bio || "";
|
||||
const email = siteConfig.profile.email || "";
|
||||
const avatarRaw = siteConfig.profile?.avatar || "";
|
||||
const avatar = resolveUrl(avatarRaw);
|
||||
return JSON.stringify({ name, url, desc, email, avatar }, null, 2);
|
||||
});
|
||||
|
||||
const openForm = () => {
|
||||
showFormModal.value = true;
|
||||
};
|
||||
|
||||
// resolve possible local paths to absolute URLs using site meta URL
|
||||
const resolveUrl = (p) => {
|
||||
if (!p) return "";
|
||||
const s = String(p).trim();
|
||||
if (/^https?:\/\//i.test(s) || /^\/\//.test(s)) return s;
|
||||
const base = (siteConfig.siteMeta && siteConfig.siteMeta.url) ? String(siteConfig.siteMeta.url).replace(/\/$/, "") : "";
|
||||
if (!base) return s;
|
||||
if (s.startsWith("/")) return base + s;
|
||||
return base + "/" + s;
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const resp = await fetch("/api/send-mail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (!resp.ok) throw new Error("send failed");
|
||||
form.name = "";
|
||||
form.url = "";
|
||||
form.desc = "";
|
||||
form.email = "";
|
||||
form.avatar = "";
|
||||
form.message = "";
|
||||
message.value = "提交成功,已发送申请邮件";
|
||||
showFormModal.value = false;
|
||||
dialogTitle.value = "提交成功";
|
||||
dialogText.value = "已发送申请邮件,感谢你的提交,将会尽快审核并在通过后通过邮件联系。";
|
||||
showDialog.value = true;
|
||||
} catch (e) {
|
||||
message.value = "提交失败,请稍后重试";
|
||||
dialogTitle.value = "提交失败";
|
||||
dialogText.value = "邮件发送失败,请稍后重试或检查网络连接。";
|
||||
showDialog.value = true;
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false;
|
||||
};
|
||||
</script>
|
||||
50
app/components/GithubSection.vue
Normal file
50
app/components/GithubSection.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1">GitHub</h2>
|
||||
<div class="mt-3">
|
||||
<h3 class="m-0 mb-1">提交热力图</h3>
|
||||
<p class="text-text-muted text-sm m-0 mb-3 block">我的提交热力图 · Acitivity Heatmap</p>
|
||||
<img :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy"
|
||||
class="rounded-xl border border-white/10 hover:border-primary/30 transition-all duration-200"
|
||||
class="w-full rounded-2xl border border-white/10" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h3 class="m-0 mb-1">常用语言</h3>
|
||||
<p class="text-text-muted text-sm m-0 mb-3 block">我常用的语言 · Languages</p>
|
||||
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
|
||||
<li v-for="lang in topLanguages" :key="lang.name"
|
||||
class="bg-white/5 border border-white/10 rounded-xl p-2.5">
|
||||
<div class="flex items-center gap-2 font-semibold mb-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded-full inline-block"
|
||||
:style="{ background: colorFor(lang.name) }"></span>
|
||||
<span class="text-text-primary">{{ lang.name }}</span>
|
||||
<span class="text-text-muted text-sm">{{ lang.percent }}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
|
||||
<span class="block h-full rounded-full transition-all duration-300"
|
||||
:style="barStyle(lang)"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
const props = defineProps({ github: Object });
|
||||
const github = props.github;
|
||||
|
||||
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
|
||||
|
||||
const topLanguages = computed(() => (Array.isArray(github.languages) ? github.languages.slice(0, 5) : []));
|
||||
|
||||
const colorFor = (name) => {
|
||||
const idx = github.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),
|
||||
});
|
||||
</script>
|
||||
20
app/components/HeroSection.vue
Normal file
20
app/components/HeroSection.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="card grid grid-cols-[120px_1fr] gap-4 items-center hover:shadow-lg-dark group">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-gradient-to-br from-primary/30 to-accent/20 blur-xl group-hover:blur-2xl transition-all duration-300 opacity-0 group-hover:opacity-100">
|
||||
</div>
|
||||
<img class="relative w-30 h-30 rounded-full object-cover border-2 border-primary/40 shadow-md-dark bg-white transition-transform duration-300 group-hover:scale-105"
|
||||
:src="profile.avatar" alt="avatar" loading="lazy" />
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<h1 class="text-2xl font-bold">{{ profile.name }}</h1>
|
||||
<p class="text-text-muted text-sm mt-1">{{ profile.title }}</p>
|
||||
<p class="mt-2 line-clamp-2">{{ profile.bio }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ profile: Object });
|
||||
</script>
|
||||
24
app/components/MusicPlayer.vue
Normal file
24
app/components/MusicPlayer.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div v-if="music.enable && (music.playlistId || music.songId)" class="netease-mini-player"
|
||||
:data-playlist-id="music.mode === 'floating' ? music.playlistId : undefined"
|
||||
:data-song-id="music.mode === 'embed' ? music.songId : undefined" :data-embed="music.mode === 'embed'"
|
||||
:data-position="music.position" :data-lyric="music.lyric" :data-theme="music.theme"
|
||||
:data-autoplay="music.autoplay" :data-default-minimized="music.defaultMinimized"
|
||||
:data-auto-pause="music.autoPause" :data-api-urls="JSON.stringify(music.apiUrls)"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const music = siteConfig.music;
|
||||
</script>
|
||||
|
||||
<!-- <style scoped>
|
||||
/* 音乐播放器样式由 NeteaseMiniPlayer 提供 */
|
||||
/* 使用 display: contents 使外层容器不占用空间 */
|
||||
/* 确保播放器浮动定位,不影响页面布局 */
|
||||
:deep(.netease-mini-player) {
|
||||
position: fixed !important;
|
||||
z-index: 999 !important;
|
||||
}
|
||||
</style> -->
|
||||
51
app/components/PageSwitcher.vue
Normal file
51
app/components/PageSwitcher.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="my-4 mx-auto max-w-3xl w-full px-4 py-3 grid grid-cols-[auto_1fr_auto] gap-3 items-center">
|
||||
<button :disabled="currentIndex <= 0" @click="goPrev"
|
||||
class="bg-white/10 text-text-primary border border-white/15 rounded-2xl px-3 py-2 cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
上一页
|
||||
</button>
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<button v-for="item in pages" :key="item.name" :class="{
|
||||
'bg-primary/30 border-primary/60 text-primary shadow-lg shadow-primary/25':
|
||||
item.name === route.name,
|
||||
}" @click="router.push({ name: item.name })"
|
||||
class="px-2.5 py-2 bg-white/10 text-text-primary border border-white/15 rounded-2xl cursor-pointer transition-all duration-200 hover:bg-white/15 hover:border-primary/40">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button :disabled="currentIndex >= pages.length - 1" @click="goNext"
|
||||
class="bg-white/10 text-text-primary border border-white/15 rounded-2xl px-3 py-2 cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const pages = [
|
||||
{ name: "index", label: "首页" },
|
||||
{ name: "about", label: "关于" },
|
||||
{ name: "sites", label: "网站" },
|
||||
{ name: "projects", label: "项目" },
|
||||
{ name: "friends", label: "友链" },
|
||||
];
|
||||
|
||||
const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
|
||||
|
||||
const goPrev = () => {
|
||||
if (currentIndex.value > 0) {
|
||||
router.push({ name: pages[currentIndex.value - 1].name });
|
||||
}
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (currentIndex.value < pages.length - 1) {
|
||||
router.push({ name: pages[currentIndex.value + 1].name });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
35
app/components/ProjectsSection.vue
Normal file
35
app/components/ProjectsSection.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<section class="card panel flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1 text-lg font-semibold">项目作品</h2>
|
||||
|
||||
<p class="text-sm text-white/60 mb-3">一些正在维护或已发布的项目 · Projects</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
|
||||
<article v-for="p in projects" :key="p.url"
|
||||
class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-yellow-400/50 w-[290px] h-[145px] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<h3 class="font-medium truncate">
|
||||
{{ p.name }}
|
||||
</h3>
|
||||
|
||||
<span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-sky-300"> 项目 </span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
|
||||
{{ p.desc }}
|
||||
</p>
|
||||
|
||||
<a :href="p.url" target="_blank" rel="noreferrer"
|
||||
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-yellow-300 hover:text-yellow-400 transition-all duration-200 hover:gap-2">
|
||||
查看仓库 →
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
projects: Array,
|
||||
});
|
||||
</script>
|
||||
35
app/components/SitesSection.vue
Normal file
35
app/components/SitesSection.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<section class="card panel flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1 text-lg font-semibold">项目作品</h2>
|
||||
|
||||
<p class="text-sm text-white/60 mb-3">一些正在维护或已发布的项目 · Projects</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
|
||||
<article v-for="site in sites" :key="site.url"
|
||||
class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-blue-400/50 w-[290px] h-[145px] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<h3 class="font-medium truncate">
|
||||
{{ site.name }}
|
||||
</h3>
|
||||
|
||||
<span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-green-300"> 在线 </span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
|
||||
{{ site.desc }}
|
||||
</p>
|
||||
|
||||
<a :href="site.url" target="_blank" rel="noreferrer"
|
||||
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-blue-300 hover:text-blue-400 transition-all duration-200 hover:gap-2">
|
||||
查看 →
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
sites: Array,
|
||||
});
|
||||
</script>
|
||||
34
app/components/SitesSection_old.vue
Normal file
34
app/components/SitesSection_old.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1 gradient-text">我的网站</h2>
|
||||
<p class="text-text-muted text-sm m-0 mb-3 block">正在运行的站点 · Websites</p>
|
||||
<div class="w-full -mx-[1.125rem] -mb-[1.125rem]">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-[1.125rem] pb-[1.125rem]">
|
||||
<article
|
||||
v-for="site in sites"
|
||||
:key="site.url"
|
||||
class="bg-gradient-to-br from-white/5 to-white/1 border border-white/10 rounded-2xl p-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-blue-400/60 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-yellow-500/6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<h3 class="m-0 font-semibold text-base">{{ site.name }}</h3>
|
||||
<span class="px-2.5 py-1 rounded-full bg-green-500/14 text-green-300 text-xs font-medium"
|
||||
>在线</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-text-muted text-sm m-0 mb-2.5">{{ site.desc }}</p>
|
||||
<a
|
||||
:href="site.url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex items-center gap-1.5 mt-2.5 text-blue-400 font-semibold text-sm hover:text-blue-300 transition-all duration-200 hover:gap-2"
|
||||
>查看 →</a
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ sites: Array });
|
||||
</script>
|
||||
28
app/components/SkillsSection.vue
Normal file
28
app/components/SkillsSection.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-2.5">
|
||||
<div>
|
||||
<h2 class="m-0 mb-1">技能专长</h2>
|
||||
<p class="text-text-muted text-sm m-0 mb-3">我常用的工具与技术 · Skills & Technologies</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<article v-for="group in skills" :key="group.title"
|
||||
class="bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-primary/50 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-primary/8">
|
||||
<header class="mb-3">
|
||||
<h3 class="text-base font-semibold m-0">{{ group.title }}</h3>
|
||||
</header>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="item in group.items" :key="item"
|
||||
class="inline-flex items-center p-1.5 rounded-2xl bg-primary/14 border border-primary/18 transition-all duration-200 hover:bg-primary/24 hover:border-primary/40 hover:scale-110">
|
||||
<img :src="iconSrc(item)" :alt="item" :title="item" loading="lazy"
|
||||
class="w-7 h-7 rounded-2xl" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ skills: { type: Array, default: () => [] } });
|
||||
const iconSrc = (id) => `https://skillicons.dev/icons?i=${encodeURIComponent(id)}&theme=dark`;
|
||||
</script>
|
||||
62
app/components/SocialLinks.vue
Normal file
62
app/components/SocialLinks.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-2.5">
|
||||
<h2 class="m-0 mb-1">社交链接</h2>
|
||||
<p class="text-text-muted text-sm m-0 mb-3 block">社交账号 · Links</p>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<a v-for="link in links" :key="link.url" :href="link.url" target="_blank" rel="noreferrer"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/10 backdrop-blur-sm border border-white/10 text-text-primary text-sm font-medium transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary hover:-translate-y-1">
|
||||
<span v-if="iconFor(link)" class="inline-flex items-center justify-center w-5 h-5">
|
||||
<i v-if="iconFor(link).fa" :class="iconFor(link).fa"></i>
|
||||
<img v-else :src="iconFor(link).src" :alt="link.name" loading="lazy" class="w-full h-full" />
|
||||
</span>
|
||||
<span>{{ link.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
const props = defineProps({ links: Array });
|
||||
|
||||
const iconMap = {
|
||||
bilibili: "fa-brands fa-bilibili",
|
||||
github: "fa-brands fa-github",
|
||||
blog: "fa-solid fa-rss",
|
||||
email: "fa-solid fa-envelope",
|
||||
mail: "fa-solid fa-envelope",
|
||||
telegram: "fa-brands fa-telegram",
|
||||
twitter: "fa-brands fa-x-twitter",
|
||||
x: "fa-brands fa-x-twitter",
|
||||
linkedin: "fa-brands fa-linkedin",
|
||||
youtube: "fa-brands fa-youtube",
|
||||
facebook: "fa-brands fa-facebook",
|
||||
instagram: "fa-brands fa-instagram",
|
||||
reddit: "fa-brands fa-reddit",
|
||||
discord: "fa-brands fa-discord",
|
||||
weibo: "fa-brands fa-weibo",
|
||||
zhihu: "fa-brands fa-zhihu",
|
||||
wechat: "fa-brands fa-weixin",
|
||||
weixin: "fa-brands fa-weixin",
|
||||
qq: "fa-brands fa-qq",
|
||||
};
|
||||
|
||||
const iconFor = (link) => {
|
||||
const key = (link.name || "").toLowerCase();
|
||||
if (iconMap[key]) return { fa: iconMap[key] };
|
||||
if (link.icon) return { src: link.icon };
|
||||
return null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const id = "fa-cdn";
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css?font-display=swap";
|
||||
link.crossOrigin = "anonymous";
|
||||
link.referrerPolicy = "no-referrer";
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
</script>
|
||||
392
app/components/StatsSection.vue
Normal file
392
app/components/StatsSection.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<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, 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()}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
329
app/components/WakatimeSection.vue
Normal file
329
app/components/WakatimeSection.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user