mirror of
https://github.com/RhenCloud/Cloud-Home.git
synced 2026-01-22 17:39:07 +08:00
Compare commits
4 Commits
b5b5509320
...
a6d4c8a27b
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d4c8a27b | |||
| 82574d38b9 | |||
| 55f4307b13 | |||
| 99ffc73e76 |
10
.env.example
10
.env.example
@@ -1,8 +1,14 @@
|
||||
# Github Token
|
||||
VITE_GITHUB_TOKEN=your-github-token
|
||||
NUXT_PUBLIC_GITHUB_TOKEN=your-github-token
|
||||
|
||||
# UMAMI API KEY
|
||||
VITE_UMAMI_API_KEY=your-umami-api-key
|
||||
NUXT_PUBLIC_UMAMI_API_KEY=your-umami-api-key
|
||||
|
||||
# Wakatime API Key
|
||||
WAKATIME_API_KEY=your-wakatime-api-key
|
||||
|
||||
# Wakatime API Base(可选)
|
||||
WAKATIME_API_URL=https://wakatime.com/api/v1
|
||||
|
||||
# SMTP 服务器地址
|
||||
SMTP_HOST=smtp.example.com
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,9 @@ build/
|
||||
.next/
|
||||
.vercel/
|
||||
.vite/
|
||||
.nuxt/
|
||||
.output/
|
||||
.edgeone
|
||||
|
||||
# env & secrets
|
||||
.env
|
||||
|
||||
33
README.md
33
README.md
@@ -1,6 +1,6 @@
|
||||
# Cloud Home
|
||||
|
||||
一个基于 Vue 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
||||
一个基于 Nuxt 3 (Vue 3) 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:Vue 3 + HTML + CSS
|
||||
- 构建:Vite
|
||||
- 部署:Vercel(静态构建 + Serverless Functions)
|
||||
- 前端:Nuxt 3(Vue 3)+ HTML + CSS
|
||||
- 构建 / 运行:Nuxt 3 + Nitro
|
||||
- 部署:Vercel(Nuxt 构建 + Nitro 函数)
|
||||
|
||||
## 致谢
|
||||
|
||||
排名不分先后
|
||||
|
||||
- [Skill Icons](https://github.com/tandpfun/skill-icons):技能图标库,本项目的技能图标来源。
|
||||
- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。
|
||||
- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。(本项目使用[本人fork的版本](https://github.com/RhenCloud/NeteaseMiniPlayer))
|
||||
|
||||
感谢以上开源项目原作者与维护者的贡献。
|
||||
|
||||
@@ -35,7 +35,7 @@ const siteConfig: SiteConfig = {
|
||||
profile: {
|
||||
name: "Example User", // 你的名字
|
||||
title: "I'm a software developer.", // 你的简介,可为空
|
||||
avatar: "avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
||||
avatar: "/avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
||||
bio: "Hello World", // 你的喜欢的一句话,可为空
|
||||
birthday: "xxxx-xx-xx", // 你的生日,可为空
|
||||
gender: "", // 你的性别,可为空
|
||||
@@ -66,7 +66,7 @@ const siteConfig: SiteConfig = {
|
||||
|
||||
siteMeta: {
|
||||
title: "Example Title", // 网站标题
|
||||
icon: "favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
||||
icon: "/favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
||||
startDate:"xxxx-xx-xx", // 网站创建日期
|
||||
},
|
||||
|
||||
@@ -90,6 +90,10 @@ const siteConfig: SiteConfig = {
|
||||
autoplay: false,
|
||||
// 是否默认以黑胶唱片状态启动(仅浮动模式)
|
||||
defaultMinimized: true,
|
||||
// 标签页非激活时是否自动暂停
|
||||
autoPause: false,
|
||||
// Music API 配置
|
||||
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
|
||||
},
|
||||
|
||||
umami: {
|
||||
@@ -166,16 +170,17 @@ const siteConfig: SiteConfig = {
|
||||
|
||||
在 Vercel 控制台或本地 `.env` 配置:
|
||||
|
||||
- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选)
|
||||
- `UMAMI_API_KEY`: Umami 分析的 API Key。
|
||||
- `NUXT_PUBLIC_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选)
|
||||
- `NUXT_PUBLIC_UMAMI_API_KEY`: 可选的 Umami API Key,用于展示访问量统计数据。
|
||||
- `WAKATIME_API_KEY`: Wakatime API Key,用于获取编码统计数据。
|
||||
- `WAKATIME_API_URL`: Wakatime API 地址,覆盖默认 `https://wakatime.com/api/v1`(可选)。
|
||||
- `SMTP_HOST`: 邮件服务器主机名
|
||||
- `SMTP_PORT`: 端口(如 465 或 587)
|
||||
- `SMTP_USER`: 发件人邮箱账号
|
||||
- `SMTP_PASS`: 邮箱授权码或密码
|
||||
- `MAIL_FROM`: 发件人地址(通常同 SMTP_USER)
|
||||
- `SENDER_EMAIL`: 发件人地址(通常同 SMTP_USER)
|
||||
- `ADMIN_EMAIL`: 接收通知的邮箱地址
|
||||
- `SMTP_SECURE`:是否启用 SSL/TLS 加密(默认 `true`)
|
||||
- `SMTP_SECURE`:是否强制启用 SSL/TLS(默认为 `true` 当端口为 465)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -184,7 +189,7 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 `http://localhost:5173/`。
|
||||
访问 `http://localhost:3000/`。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -192,7 +197,7 @@ pnpm dev
|
||||
pnpm build
|
||||
```
|
||||
|
||||
产物输出到
|
||||
产物输出到 Nuxt 的 `.output/` 目录,该目录同时包含静态资源与 Nitro 服务器入口。
|
||||
|
||||
## 部署到 Vercel
|
||||
|
||||
@@ -201,6 +206,8 @@ pnpm build
|
||||
|
||||
## API
|
||||
|
||||
雁型的 Nitro 路由位于 `server/api`,依旧暴露同样的 `/api` 前缀。
|
||||
|
||||
- `POST /api/send-mail`:友链申请邮件发送。请求体示例:
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
// CORS 处理
|
||||
// res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
// res.setHeader("Access-Control-Allow-Methods", "POST, 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(200).json({ status: "ok", message: "use POST to send mail" });
|
||||
// }
|
||||
|
||||
// if (req.method !== "POST") {
|
||||
// return res.status(405).json({ error: "Method Not Allowed" });
|
||||
// }
|
||||
|
||||
try {
|
||||
const data = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
const { name, email, site, desc, avatar } = data || {};
|
||||
if (!name || !email || !desc) {
|
||||
return res.status(400).json({ error: "Missing required fields: name, email, desc" });
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT || 465),
|
||||
secure: Number(process.env.SMTP_PORT || 465) === 465,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SENDER_EMAIL,
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: "友链申请 / 联系表单",
|
||||
html: `
|
||||
<p><strong>名称:</strong>${name}</p>
|
||||
<p><strong>邮箱:</strong>${email}</p>
|
||||
<p><strong>站点:</strong>${site || "未填写"}</p>
|
||||
<p><strong>描述:</strong>${desc}</p>
|
||||
<p><strong>头像:</strong>${avatar || "未填写"}</p>
|
||||
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
||||
`,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Mail sent",
|
||||
id: info.messageId,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Send mail failed", detail: (err as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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" });
|
||||
}
|
||||
}
|
||||
35
nuxt.config.ts
Normal file
35
nuxt.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineNuxtConfig } from "nuxt/config";
|
||||
import siteConfig from "./src/config/siteConfig";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-12-12",
|
||||
srcDir: "src/",
|
||||
css: ["~/styles.css"],
|
||||
app: {
|
||||
head: {
|
||||
title: siteConfig.siteMeta.title,
|
||||
link: [{ rel: "icon", href: siteConfig.siteMeta.icon }],
|
||||
},
|
||||
},
|
||||
// nitro: {
|
||||
// prerender: {
|
||||
// crawlLinks: true,
|
||||
// routes: ["/sitemap.xml", "/rss.xml"],
|
||||
// },
|
||||
// },
|
||||
runtimeConfig: {
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: Number(process.env.SMTP_PORT ?? 465),
|
||||
smtpUser: process.env.SMTP_USER ?? "",
|
||||
smtpPass: process.env.SMTP_PASS ?? "",
|
||||
senderEmail: process.env.SENDER_EMAIL ?? "",
|
||||
adminEmail: process.env.ADMIN_EMAIL ?? "",
|
||||
smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined,
|
||||
wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "",
|
||||
wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1",
|
||||
public: {
|
||||
githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "",
|
||||
umamiApiKey: process.env.NUXT_PUBLIC_UMAMI_API_KEY ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
20
package.json
20
package.json
@@ -3,27 +3,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jaseeey/vue-umami-plugin": "^1.4.0",
|
||||
"@vercel/node": "^5.5.15",
|
||||
"cros": "^1.1.0",
|
||||
"express": "^5.2.1",
|
||||
"he": "^1.2.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.3"
|
||||
"nuxt": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vue-tsc": "^3.1.6"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
1340
public/css/netease-mini-player-v2.css
Normal file
1340
public/css/netease-mini-player-v2.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
103
server/api/send-mail.ts
Normal file
103
server/api/send-mail.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { defineEventHandler, createError, readBody } from "h3";
|
||||
import nodemailer from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
|
||||
type MailConfig = {
|
||||
smtpHost?: string;
|
||||
smtpPort?: number | string;
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
senderEmail?: string;
|
||||
adminEmail?: string;
|
||||
smtpSecure?: boolean;
|
||||
};
|
||||
|
||||
type SendMailPayload = {
|
||||
name?: string;
|
||||
url?: string;
|
||||
desc?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const ensureValue = (value?: string, fallback = "未填写") => (value?.trim() ? value.trim() : fallback);
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = event.node.req.method;
|
||||
if (method === "OPTIONS") {
|
||||
event.node.res.statusCode = 200;
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
if (method !== "POST") {
|
||||
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const payload = (await readBody<SendMailPayload>(event)) || {};
|
||||
const { name, url, desc, email, avatar, message } = payload;
|
||||
|
||||
if (!name?.trim() || !url?.trim() || !email?.trim()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing required fields: name, url, and email",
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig() as MailConfig;
|
||||
const { smtpHost, smtpPort: configSmtpPort, smtpUser, smtpPass, senderEmail, adminEmail, smtpSecure } = config;
|
||||
|
||||
const smtpPort = Number(configSmtpPort ?? 465);
|
||||
if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) {
|
||||
throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" });
|
||||
}
|
||||
|
||||
const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465;
|
||||
const smtpOptions: SMTPTransport.Options = {
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(smtpOptions);
|
||||
const friendEntry = `{
|
||||
name: "${ensureValue(name).replace(/"/g, '\\"')}",
|
||||
url: "${ensureValue(url).replace(/"/g, '\\"')}",
|
||||
desc: "${ensureValue(desc).replace(/"/g, '\\"')}",
|
||||
avatar: "${ensureValue(avatar).replace(/"/g, '\\"')}",
|
||||
},`;
|
||||
|
||||
const htmlMessage = `
|
||||
<p>一个新的友链申请已提交,以下是可直接复制到项目中的配置:</p>
|
||||
<pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;">
|
||||
<code>${friendEntry}</code>
|
||||
</pre>
|
||||
<hr style="margin: 20px 0;" />
|
||||
<p><strong>申请者信息:</strong></p>
|
||||
<p><strong>名称:</strong>${ensureValue(name)}</p>
|
||||
<p><strong>邮箱:</strong>${ensureValue(email)}</p>
|
||||
<p><strong>站点:</strong><a href="${ensureValue(url)}">${ensureValue(url)}</a></p>
|
||||
<p><strong>描述:</strong>${ensureValue(desc)}</p>
|
||||
<p><strong>头像:</strong>${ensureValue(avatar)}</p>
|
||||
<p><strong>想说的话:</strong>${ensureValue(message)}</p>
|
||||
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
||||
`;
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: senderEmail,
|
||||
to: adminEmail,
|
||||
replyTo: email,
|
||||
subject: `友链申请 / 联系表单 · ${ensureValue(name)}`,
|
||||
html: htmlMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Mail sent",
|
||||
id: info.messageId,
|
||||
};
|
||||
});
|
||||
56
server/api/wakatime.ts
Normal file
56
server/api/wakatime.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineEventHandler, getQuery, createError } from "h3";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const res = event.node.res;
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (event.node.req.method === "OPTIONS") {
|
||||
res.statusCode = 200;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
if (event.node.req.method !== "GET") {
|
||||
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const apiKey = config.wakatimeApiKey;
|
||||
if (typeof apiKey !== "string") {
|
||||
throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" });
|
||||
}
|
||||
|
||||
const query = getQuery(event);
|
||||
const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([
|
||||
fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }),
|
||||
fetch(`${apiUrl}/users/current/stats/all_time`, { headers }),
|
||||
fetch(`${apiUrl}/users/current/status`, { headers }),
|
||||
]);
|
||||
|
||||
if (!weeklyStatsResponse.ok) {
|
||||
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
|
||||
}
|
||||
|
||||
const weeklyStatsData = await weeklyStatsResponse.json();
|
||||
const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null;
|
||||
const statusData = statusResponse.ok ? await statusResponse.json() : null;
|
||||
|
||||
return {
|
||||
weekly: weeklyStatsData.data,
|
||||
allTime: allTimeStatsData ? allTimeStatsData.data : null,
|
||||
status: statusData,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Wakatime API error:", error);
|
||||
throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" });
|
||||
}
|
||||
});
|
||||
215
src/App.vue
215
src/App.vue
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<div class="app-shell" :style="backgroundStyle">
|
||||
<div class="background-overlay" :style="overlayStyle"></div>
|
||||
<button
|
||||
class="background-toggle"
|
||||
@click="hideComponents = !hideComponents"
|
||||
:title="hideComponents ? '显示内容' : '隐藏内容'"
|
||||
:class="{ active: hideComponents }"
|
||||
>
|
||||
<span class="toggle-icon">{{ hideComponents ? "👁️" : "🙈" }}</span>
|
||||
<span class="toggle-label">{{ hideComponents ? "显示" : "隐藏" }}</span>
|
||||
</button>
|
||||
<Transition name="fade-down">
|
||||
<main class="app-body" v-if="!hideComponents" key="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</Transition>
|
||||
<Transition name="fade-up">
|
||||
<PageSwitcher v-if="!hideComponents" key="switcher" />
|
||||
</Transition>
|
||||
<Transition name="fade-down">
|
||||
<FooterSection v-if="!hideComponents" :contact="contact" key="footer" />
|
||||
</Transition>
|
||||
<!-- 音乐播放器 -->
|
||||
<MusicPlayer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from "vue";
|
||||
import PageSwitcher from "./components/PageSwitcher.vue";
|
||||
import FooterSection from "./components/FooterSection.vue";
|
||||
import MusicPlayer from "./components/MusicPlayer.vue";
|
||||
import siteConfig from "./config/siteConfig";
|
||||
|
||||
const contact = siteConfig.footer;
|
||||
const bg = siteConfig.appearance.background;
|
||||
const isMobile = ref(false);
|
||||
const hideComponents = ref(false);
|
||||
|
||||
// 检测是否为移动设备
|
||||
const checkIfMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkIfMobile();
|
||||
window.addEventListener("resize", checkIfMobile);
|
||||
});
|
||||
|
||||
const getBackgroundImage = () => {
|
||||
if (!bg.enable) return undefined;
|
||||
|
||||
// 根据屏幕尺寸选择图片
|
||||
const image = isMobile.value && bg.mobileImage ? bg.mobileImage : bg.image;
|
||||
|
||||
if (!image) return undefined;
|
||||
|
||||
return image.startsWith("http") ? image : `/${image}`;
|
||||
};
|
||||
|
||||
const backgroundStyle = computed(() => {
|
||||
const imageUrl = getBackgroundImage();
|
||||
|
||||
if (!imageUrl) return {};
|
||||
|
||||
return {
|
||||
backgroundImage: `url('${imageUrl}')`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundAttachment: "fixed",
|
||||
filter: `blur(${bg.blur}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const overlayStyle = computed(() => {
|
||||
if (!bg.enable || !getBackgroundImage()) return {};
|
||||
return { backgroundColor: bg.overlay };
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.background-toggle {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 50px;
|
||||
background: linear-gradient(135deg, rgba(124, 193, 255, 0.15), rgba(124, 193, 255, 0.05));
|
||||
border: 1.5px solid rgba(124, 193, 255, 0.3);
|
||||
color: #7cc1ff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
box-shadow: 0 8px 32px rgba(124, 193, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.background-toggle:hover {
|
||||
background: linear-gradient(135deg, rgba(124, 193, 255, 0.25), rgba(124, 193, 255, 0.1));
|
||||
border-color: rgba(124, 193, 255, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(124, 193, 255, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.background-toggle:active {
|
||||
transform: translateY(0px);
|
||||
box-shadow: 0 4px 16px rgba(124, 193, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.background-toggle.active {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.2), rgba(255, 107, 107, 0.05));
|
||||
border-color: rgba(255, 107, 107, 0.4);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.background-toggle.active:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.3), rgba(255, 107, 107, 0.1));
|
||||
border-color: rgba(255, 107, 107, 0.6);
|
||||
box-shadow: 0 12px 40px rgba(255, 107, 107, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.background-toggle {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
/* 上段组件:向上淡出,向下淡入 */
|
||||
.fade-up-enter-active,
|
||||
.fade-up-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.fade-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.fade-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
/* 下段组件:向下淡出,向上淡入 */
|
||||
.fade-down-enter-active,
|
||||
.fade-down-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.fade-down-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.fade-down-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
</style>
|
||||
83
src/app.vue
Normal file
83
src/app.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="app-shell" :style="backgroundStyle">
|
||||
<div class="background-overlay" :style="overlayStyle"></div>
|
||||
<button
|
||||
class="background-toggle"
|
||||
@click="hideComponents = !hideComponents"
|
||||
:title="hideComponents ? '显示内容' : '隐藏内容'"
|
||||
:class="{ active: hideComponents }"
|
||||
>
|
||||
<span class="toggle-icon">{{ hideComponents ? "👁️" : "🙈" }}</span>
|
||||
<span class="toggle-label">{{ hideComponents ? "显示" : "隐藏" }}</span>
|
||||
</button>
|
||||
<div class="content-stack">
|
||||
<Transition name="fade-down">
|
||||
<main class="app-body" v-if="!hideComponents" key="content">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
</Transition>
|
||||
<Transition name="fade-up">
|
||||
<PageSwitcher v-if="!hideComponents" key="switcher" />
|
||||
</Transition>
|
||||
<Transition name="fade-down">
|
||||
<FooterSection v-if="!hideComponents" :contact="contact" key="footer" />
|
||||
</Transition>
|
||||
</div>
|
||||
<MusicPlayer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from "vue";
|
||||
import PageSwitcher from "~/components/PageSwitcher.vue";
|
||||
import FooterSection from "~/components/FooterSection.vue";
|
||||
import MusicPlayer from "~/components/MusicPlayer.vue";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const contact = siteConfig.footer;
|
||||
const bg = siteConfig.appearance.background;
|
||||
const isMobile = ref(false);
|
||||
const hideComponents = ref(false);
|
||||
|
||||
const checkIfMobile = () => {
|
||||
isMobile.value = typeof window !== "undefined" && window.innerWidth <= 768;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkIfMobile();
|
||||
window.addEventListener("resize", checkIfMobile);
|
||||
// const script = document.createElement("script");
|
||||
// script.src = "/js/netease-mini-player-v2.js";
|
||||
// document.head.appendChild(script);
|
||||
});
|
||||
|
||||
const getBackgroundImage = () => {
|
||||
if (!bg.enable) return undefined;
|
||||
|
||||
const image = isMobile.value && bg.mobileImage ? bg.mobileImage : bg.image;
|
||||
|
||||
if (!image) return undefined;
|
||||
|
||||
return image.startsWith("http") ? image : `/${image}`;
|
||||
};
|
||||
|
||||
const backgroundStyle = computed(() => ({ backgroundColor: "#0f1629" }));
|
||||
|
||||
const overlayStyle = computed(() => {
|
||||
const imageUrl = getBackgroundImage();
|
||||
|
||||
if (!bg.enable || !imageUrl) return {};
|
||||
|
||||
return {
|
||||
backgroundImage: `linear-gradient(${bg.overlay}, ${bg.overlay}), url('${imageUrl}')`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundAttachment: "fixed",
|
||||
filter: bg.blur ? `blur(${bg.blur}px)` : undefined,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- <style>
|
||||
@import "/css/netease-mini-player-v2.css";
|
||||
</style> -->
|
||||
@@ -18,8 +18,10 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
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);
|
||||
@@ -60,7 +62,7 @@ const fetchStats = async () => {
|
||||
}
|
||||
const apiBase = siteConfig.umami.apiBase;
|
||||
const websiteId = siteConfig.umami.websiteId;
|
||||
const apiKey = import.meta.env.VITE_UMAMI_API_KEY;
|
||||
const apiKey = config.public.umamiApiKey;
|
||||
|
||||
if (!apiKey) return;
|
||||
|
||||
@@ -105,7 +107,8 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.from {
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
头像链接
|
||||
<input v-model="form.avatar" type="url" placeholder="可选,展示头像" />
|
||||
</label>
|
||||
<label>
|
||||
想说的话
|
||||
<div class="textarea-wrapper">
|
||||
<textarea v-model="form.message" placeholder="可选,最多50字" maxlength="50"></textarea>
|
||||
<span class="char-count">{{ form.message?.length || 0 }}/50</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary" :disabled="loading">
|
||||
{{ loading ? "提交中..." : "提交申请" }}
|
||||
@@ -75,6 +82,7 @@ const form = reactive({
|
||||
desc: "",
|
||||
email: "",
|
||||
avatar: "",
|
||||
message: "",
|
||||
});
|
||||
const displayedFriends = ref([]);
|
||||
|
||||
@@ -108,6 +116,7 @@ const submitForm = async () => {
|
||||
desc: form.desc,
|
||||
email: form.email,
|
||||
avatar: form.avatar,
|
||||
message: form.message,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error("send failed");
|
||||
@@ -192,15 +201,41 @@ h2 {
|
||||
color: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.friend-form input::placeholder {
|
||||
.textarea-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.friend-form textarea {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
font-family: inherit;
|
||||
height: 36px;
|
||||
resize: none;
|
||||
overflow: auto;
|
||||
}
|
||||
.friend-form input::placeholder,
|
||||
.friend-form textarea::placeholder {
|
||||
color: rgba(232, 238, 252, 0.7);
|
||||
}
|
||||
.friend-form input:focus {
|
||||
.friend-form input:focus,
|
||||
.friend-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgba(124, 193, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(124, 193, 255, 0.25);
|
||||
}
|
||||
.char-count {
|
||||
font-size: 12px;
|
||||
color: rgba(232, 238, 252, 0.6);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.form-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
|
||||
@@ -25,6 +25,8 @@ defineProps({ profile: Object });
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border: 3px solid var(--accent);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
: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";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const music = siteConfig.music;
|
||||
</script>
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
<button :disabled="currentIndex <= 0" @click="goPrev">上一页</button>
|
||||
<div class="dots">
|
||||
<button
|
||||
v-for="r in ordered"
|
||||
:key="r.name"
|
||||
:class="{ active: r.name === route.name }"
|
||||
@click="router.push({ name: r.name })"
|
||||
v-for="item in pages"
|
||||
:key="item.name"
|
||||
:class="{ active: item.name === route.name }"
|
||||
@click="router.push({ name: item.name })"
|
||||
>
|
||||
{{ r.meta.label || r.name }}
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button :disabled="currentIndex >= ordered.length - 1" @click="goNext">下一页</button>
|
||||
<button :disabled="currentIndex >= pages.length - 1" @click="goNext">下一页</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,21 +22,26 @@ import { useRoute, useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const ordered = computed(() =>
|
||||
router
|
||||
.getRoutes()
|
||||
.filter((r) => typeof r.meta?.order === "number")
|
||||
.sort((a, b) => a.meta.order - b.meta.order)
|
||||
);
|
||||
const pages = [
|
||||
{ name: "index", label: "首页" },
|
||||
{ name: "about", label: "关于" },
|
||||
{ name: "sites", label: "网站" },
|
||||
{ name: "projects", label: "项目" },
|
||||
{ name: "friends", label: "友链" },
|
||||
];
|
||||
|
||||
const currentIndex = computed(() => ordered.value.findIndex((r) => r.name === route.name));
|
||||
const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
|
||||
|
||||
const goPrev = () => {
|
||||
if (currentIndex.value > 0) router.push({ name: ordered.value[currentIndex.value - 1].name });
|
||||
if (currentIndex.value > 0) {
|
||||
router.push({ name: pages[currentIndex.value - 1].name });
|
||||
}
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (currentIndex.value < ordered.value.length - 1)
|
||||
router.push({ name: ordered.value[currentIndex.value + 1].name });
|
||||
if (currentIndex.value < pages.length - 1) {
|
||||
router.push({ name: pages[currentIndex.value + 1].name });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -53,24 +53,24 @@
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{
|
||||
currentWakatimeData.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A"
|
||||
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"
|
||||
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-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">
|
||||
<div class="lang-wrap" v-if="currentWakatimeData?.languages && currentWakatimeData.languages.length">
|
||||
<h3>编程语言</h3>
|
||||
<p class="muted">语言使用统计 · Languages</p>
|
||||
<div class="lang-chart">
|
||||
@@ -177,13 +177,10 @@ const fetchWakatimeData = async () => {
|
||||
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;
|
||||
@@ -199,34 +196,6 @@ const fetchWakatimeData = async () => {
|
||||
}
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ const siteConfig = {
|
||||
profile: {
|
||||
name: "RhenCloud",
|
||||
title: "I'm RhenCloud.",
|
||||
avatar: "avatar.webp", // public/avatar.webp
|
||||
avatar: "/avatar.webp", // public/avatar.webp
|
||||
bio: "趁世界还未重启之前 约一次爱恋",
|
||||
birthday: "2010-03-28",
|
||||
// gender: "女",
|
||||
@@ -31,7 +31,7 @@ const siteConfig = {
|
||||
|
||||
siteMeta: {
|
||||
title: "RhenCloud",
|
||||
icon: "favicon.svg", // public/favicon.svg
|
||||
icon: "/favicon.svg", // public/favicon.svg
|
||||
startDate: "2025-12-06",
|
||||
},
|
||||
|
||||
@@ -65,6 +65,10 @@ const siteConfig = {
|
||||
autoplay: false,
|
||||
// 是否默认以黑胶唱片状态启动(仅浮动模式)
|
||||
defaultMinimized: true,
|
||||
// 标签页非激活时是否自动暂停
|
||||
autoPause: false,
|
||||
// Music API 配置
|
||||
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
|
||||
},
|
||||
|
||||
umami: {
|
||||
|
||||
16
src/main.ts
16
src/main.ts
@@ -1,16 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles.css";
|
||||
import siteConfig from "./config/siteConfig";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
if (siteConfig.umami?.enable) {
|
||||
app.use(VueUmamiPlugin({ websiteID: siteConfig.umami.websiteId, scriptSrc: siteConfig.umami.url, router }));
|
||||
}
|
||||
}
|
||||
|
||||
app.use(router).mount("#app");
|
||||
75
src/pages/about.vue
Normal file
75
src/pages/about.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<HeroSection :profile="profile" />
|
||||
<SkillsSection :skills="skills" />
|
||||
<StatsSection :github="github" :wakatime="wakatime" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from "vue";
|
||||
import { useRuntimeConfig, definePageMeta } from "#imports";
|
||||
import HeroSection from "~/components/HeroSection.vue";
|
||||
import SkillsSection from "~/components/SkillsSection.vue";
|
||||
import StatsSection from "~/components/StatsSection.vue";
|
||||
import siteConfig from "@/config/siteConfig";
|
||||
|
||||
const profile = siteConfig.profile;
|
||||
const skills = siteConfig.skills;
|
||||
const wakatime = siteConfig.wakatime;
|
||||
const config = useRuntimeConfig();
|
||||
const githubToken = config.public.githubToken ?? "";
|
||||
|
||||
type GithubHeatmap = {
|
||||
username: string;
|
||||
heatmapUrl: string;
|
||||
languages?: { name: string; percent: number }[];
|
||||
};
|
||||
|
||||
const github = reactive<GithubHeatmap>({
|
||||
...siteConfig.github,
|
||||
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
|
||||
languages: [],
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
order: 1,
|
||||
label: "关于",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchGithubMeta();
|
||||
});
|
||||
|
||||
async function fetchGithubMeta() {
|
||||
try {
|
||||
const headers: HeadersInit = {};
|
||||
if (githubToken) {
|
||||
headers.Authorization = `Bearer ${githubToken}`;
|
||||
}
|
||||
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, {
|
||||
headers,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!Array.isArray(data)) return;
|
||||
type GithubRepo = { language?: string };
|
||||
const repos = data as GithubRepo[];
|
||||
const counts: Record<string, number> = {};
|
||||
repos.forEach((repo) => {
|
||||
if (!repo.language) return;
|
||||
counts[repo.language] = (counts[repo.language] || 0) + 1;
|
||||
});
|
||||
const total = Object.values(counts).reduce((sum, value) => sum + value, 0) || 1;
|
||||
const parsed = Object.entries(counts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
github.languages = parsed.map((item) => ({
|
||||
name: item.name,
|
||||
percent: Math.round((item.count / total) * 100),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch GitHub metadata:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
17
src/pages/friends.vue
Normal file
17
src/pages/friends.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<FriendsSection :friends="friends" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FriendsSection from "~/components/FriendsSection.vue";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const friends = siteConfig.friends;
|
||||
|
||||
definePageMeta({
|
||||
order: 4,
|
||||
label: "友链",
|
||||
});
|
||||
</script>
|
||||
23
src/pages/index.vue
Normal file
23
src/pages/index.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<HeroSection :profile="profile" />
|
||||
<SocialLinks :links="socialLinks" />
|
||||
<AboutSection :items="about" :profile="profile" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import HeroSection from "~/components/HeroSection.vue";
|
||||
import SocialLinks from "~/components/SocialLinks.vue";
|
||||
import AboutSection from "~/components/AboutSection.vue";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const profile = siteConfig.profile;
|
||||
const socialLinks = siteConfig.socialLinks;
|
||||
const about = siteConfig.about;
|
||||
|
||||
definePageMeta({
|
||||
order: 0,
|
||||
label: "首页",
|
||||
});
|
||||
</script>
|
||||
17
src/pages/projects.vue
Normal file
17
src/pages/projects.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<ProjectsSection :projects="projects" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectsSection from "~/components/ProjectsSection.vue";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const projects = siteConfig.projects;
|
||||
|
||||
definePageMeta({
|
||||
order: 3,
|
||||
label: "项目",
|
||||
});
|
||||
</script>
|
||||
17
src/pages/sites.vue
Normal file
17
src/pages/sites.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<SitesSection :sites="sites" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SitesSection from "~/components/SitesSection.vue";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
const sites = siteConfig.sites;
|
||||
|
||||
definePageMeta({
|
||||
order: 2,
|
||||
label: "网站",
|
||||
});
|
||||
</script>
|
||||
91
src/plugins/neteaseMiniPlayer.client.ts
Normal file
91
src/plugins/neteaseMiniPlayer.client.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { defineNuxtPlugin } from "#app";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (import.meta.server) return;
|
||||
|
||||
// 检查配置是否启用了音乐播放器
|
||||
if (!siteConfig.music?.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 在本地开发环境禁用网易音乐播放器,避免网络超时
|
||||
// if (
|
||||
// typeof window !== "undefined" &&
|
||||
// (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
|
||||
// ) {
|
||||
// console.log("Netease Music Player disabled on localhost");
|
||||
// return;
|
||||
// }
|
||||
|
||||
const cssHref = "/css/netease-mini-player-v2.css";
|
||||
const scriptSrc = "/js/netease-mini-player-v2.js";
|
||||
|
||||
const ensureStyle = () => {
|
||||
if (document.querySelector(`link[href="${cssHref}"]`)) return;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = cssHref;
|
||||
link.onerror = () => {
|
||||
console.warn("Failed to load Netease music player styles");
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
const ensureScript = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
// 检查全局对象是否已存在,表示脚本已加载
|
||||
const anyWin = window as any;
|
||||
if (anyWin.NeteaseMiniPlayer) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = document.querySelector(`script[src="${scriptSrc}"]`) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
// 脚本已存在但未加载,等待它加载
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener("error", () => resolve(), { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 脚本不存在,创建并加载
|
||||
const script = document.createElement("script");
|
||||
script.src = scriptSrc;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
console.warn("Failed to load Netease music player script");
|
||||
resolve();
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
const initPlayer = () => {
|
||||
const anyWin = window as any;
|
||||
|
||||
// 将 siteConfig 的音乐配置传递给全局 window 对象
|
||||
if (!anyWin.__NETEASE_MUSIC_CONFIG__) {
|
||||
anyWin.__NETEASE_MUSIC_CONFIG__ = siteConfig.music;
|
||||
}
|
||||
|
||||
if (anyWin.NeteaseMiniPlayer?.init) {
|
||||
try {
|
||||
anyWin.NeteaseMiniPlayer.init();
|
||||
} catch (error) {
|
||||
console.warn("Failed to initialize Netease music player:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 使用超时机制防止永久挂起
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn("Netease music player initialization timeout");
|
||||
}, 15000);
|
||||
|
||||
ensureStyle();
|
||||
ensureScript().then(() => {
|
||||
clearTimeout(timeout);
|
||||
initPlayer();
|
||||
});
|
||||
});
|
||||
29
src/plugins/umami.client.ts
Normal file
29
src/plugins/umami.client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineNuxtPlugin } from "#app";
|
||||
import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin";
|
||||
import type { Router } from "vue-router";
|
||||
import siteConfig from "~/config/siteConfig";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
if (!process.client) return;
|
||||
if (!siteConfig.umami?.enable) return;
|
||||
|
||||
// 跳过在 localhost 环境下加载 Umami
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
|
||||
) {
|
||||
console.log("Umami plugin skipped on localhost");
|
||||
return;
|
||||
}
|
||||
|
||||
const router = nuxtApp.$router as Router | undefined;
|
||||
if (!router) return;
|
||||
|
||||
nuxtApp.vueApp.use(
|
||||
VueUmamiPlugin({
|
||||
websiteID: siteConfig.umami.websiteId,
|
||||
scriptSrc: siteConfig.umami.url,
|
||||
router,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import Home from "../views/Home.vue";
|
||||
import About from "../views/About.vue";
|
||||
import Sites from "../views/Sites.vue";
|
||||
import Projects from "../views/Projects.vue";
|
||||
import Friends from "../views/Friends.vue";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/", name: "Home", component: Home, meta: { order: 0, label: "首页" } },
|
||||
{ path: "/about", name: "About", component: About, meta: { order: 1, label: "关于" } },
|
||||
{ path: "/sites", name: "Sites", component: Sites, meta: { order: 2, label: "网站" } },
|
||||
{ path: "/projects", name: "Projects", component: Projects, meta: { order: 3, label: "项目" } },
|
||||
{ path: "/friends", name: "Friends", component: Friends, meta: { order: 4, label: "友链" } },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
106
src/styles.css
106
src/styles.css
@@ -15,6 +15,42 @@ body {
|
||||
background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629);
|
||||
}
|
||||
|
||||
/* Layout shell keeps background and toggle visible even when content is hidden */
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
color: #e8eefc;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.content-stack {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #7cc1ff;
|
||||
text-decoration: none;
|
||||
@@ -106,7 +142,7 @@ p {
|
||||
/* do not force width here — let the player's own minimized/expanded
|
||||
styles control sizing. Only constrain max width as a safety net. */
|
||||
max-width: calc(100% - 40px) !important;
|
||||
z-index: 9999 !important;
|
||||
z-index: 10001 !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
@@ -127,7 +163,7 @@ p {
|
||||
bottom: 20px !important;
|
||||
left: 20px !important;
|
||||
right: auto !important;
|
||||
z-index: 9999 !important;
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
|
||||
/* Fix: prevent playlist dropdown from increasing document height
|
||||
@@ -141,7 +177,7 @@ p {
|
||||
width: 290px !important;
|
||||
max-height: 50vh !important;
|
||||
overflow: auto !important;
|
||||
z-index: 10001 !important;
|
||||
z-index: 10002 !important;
|
||||
}
|
||||
|
||||
.netease-mini-player[data-position="top-left"] .playlist-container,
|
||||
@@ -153,7 +189,7 @@ p {
|
||||
width: 290px !important;
|
||||
max-height: 50vh !important;
|
||||
overflow: auto !important;
|
||||
z-index: 10001 !important;
|
||||
z-index: 10002 !important;
|
||||
}
|
||||
|
||||
/* If player is docked to the right, align playlist to right edge */
|
||||
@@ -189,3 +225,65 @@ p {
|
||||
object-fit: cover !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* Floating background show/hide toggle (bottom-right) */
|
||||
.background-toggle {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 10000;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(10px);
|
||||
color: #f7fbff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.background-toggle:hover,
|
||||
.background-toggle:focus-visible {
|
||||
background: rgba(124, 193, 255, 0.25);
|
||||
border-color: rgba(124, 193, 255, 0.65);
|
||||
box-shadow: 0 14px 36px rgba(124, 193, 255, 0.28);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.background-toggle:active {
|
||||
transform: translateY(1px) scale(0.99);
|
||||
}
|
||||
|
||||
.background-toggle .toggle-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.background-toggle .toggle-label {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.background-toggle.active {
|
||||
background: linear-gradient(135deg, rgba(124, 193, 255, 0.4), rgba(255, 255, 255, 0.2));
|
||||
border-color: rgba(124, 193, 255, 0.8);
|
||||
color: #0f1629;
|
||||
box-shadow: 0 16px 42px rgba(124, 193, 255, 0.32);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.background-toggle {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
padding: 9px 12px;
|
||||
gap: 5px;
|
||||
}
|
||||
.background-toggle .toggle-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<HeroSection :profile="profile" />
|
||||
<SkillsSection :skills="skills" />
|
||||
<StatsSection :github="github" :wakatime="wakatime" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from "vue";
|
||||
import HeroSection from "../components/HeroSection.vue";
|
||||
import SkillsSection from "../components/SkillsSection.vue";
|
||||
import StatsSection from "../components/StatsSection.vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
const profile = siteConfig.profile;
|
||||
const siteMeta = siteConfig.siteMeta;
|
||||
const skills = siteConfig.skills;
|
||||
const wakatime = siteConfig.wakatime;
|
||||
|
||||
const github = reactive({
|
||||
...siteConfig.github,
|
||||
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
|
||||
});
|
||||
|
||||
// 修改此处:使用 VITE_ 前缀
|
||||
const githubToken = import.meta.env.VITE_GITHUB_TOKEN ?? "";
|
||||
|
||||
onMounted(() => {
|
||||
document.title = siteMeta.title;
|
||||
const link = document.querySelector("link[rel~='icon']") || document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = siteMeta.icon;
|
||||
document.head.appendChild(link);
|
||||
fetchGithubMeta();
|
||||
});
|
||||
|
||||
async function fetchGithubMeta() {
|
||||
try {
|
||||
const headers = githubToken ? { Authorization: `Bearer ${githubToken}` } : {};
|
||||
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, {
|
||||
headers,
|
||||
});
|
||||
const repos = await resp.json();
|
||||
if (!Array.isArray(repos)) return;
|
||||
const counts = repos.reduce((acc, repo) => {
|
||||
if (!repo.language) return acc;
|
||||
acc[repo.language] = (acc[repo.language] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0) || 1;
|
||||
const parsed = Object.entries(counts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
github.languages = parsed.map((item) => ({
|
||||
name: item.name,
|
||||
percent: Math.round((item.count / total) * 100),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch GitHub metadata:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<FriendsSection :friends="friends" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FriendsSection from "../components/FriendsSection.vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
const friends = siteConfig.friends;
|
||||
</script>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<HeroSection :profile="profile" />
|
||||
<SocialLinks :links="socialLinks" />
|
||||
<AboutSection :items="about" :profile="profile" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from "vue";
|
||||
import HeroSection from "../components/HeroSection.vue";
|
||||
import SocialLinks from "../components/SocialLinks.vue";
|
||||
import AboutSection from "../components/AboutSection.vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
const profile = siteConfig.profile;
|
||||
const socialLinks = siteConfig.socialLinks;
|
||||
const siteMeta = siteConfig.siteMeta;
|
||||
const about = siteConfig.about;
|
||||
|
||||
onMounted(() => {
|
||||
document.title = siteMeta.title;
|
||||
const link = document.querySelector("link[rel~='icon']") || document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = siteMeta.icon;
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<ProjectsSection :projects="projects" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectsSection from "../components/ProjectsSection.vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
const projects = siteConfig.projects;
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<SitesSection :sites="sites" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SitesSection from "../components/SitesSection.vue";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
const sites = siteConfig.sites;
|
||||
</script>
|
||||
@@ -1,25 +1,7 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"include": ["nuxt.config.ts", "src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "server/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["nuxt.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"version": 2,
|
||||
"framework": "vite",
|
||||
"routes": [
|
||||
{
|
||||
"src": "/api/(.*)",
|
||||
"dest": "/api/$1"
|
||||
}
|
||||
]
|
||||
"framework": "nuxtjs"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: { port: 5173 },
|
||||
});
|
||||
Reference in New Issue
Block a user