├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── api │ ├── music │ │ └── route.ts │ ├── proxy │ │ └── image │ │ │ └── route.ts │ └── resolve-url │ │ └── route.ts ├── fonts.ts ├── globals.css ├── layout.tsx ├── page.tsx ├── phone │ ├── PhoneContent.tsx │ └── page.tsx └── poster │ ├── SpotifyContent.tsx │ └── page.tsx ├── eslint.config.mjs ├── lib └── themes.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── cover.png ├── favicon.ico ├── phone.png ├── poster.png └── templates │ ├── background.png │ ├── catppuccin.png │ ├── dark.png │ ├── everforest.png │ ├── gruvbox.png │ ├── iphone.png │ ├── light.png │ ├── nord.png │ ├── phone.png │ └── rosepine.png ├── tailwind.config.ts ├── tsconfig.json ├── types.ts ├── types ├── index.ts ├── music.ts ├── poster.ts ├── spotify.ts └── theme.ts └── utils ├── color.ts ├── musicParser.ts ├── phone.ts ├── poster.ts ├── posterGenerator.ts ├── retry.ts └── spotify.ts /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 | Contributing Guidelines 2 | 3 | [English](#english) | [中文](#chinese) 4 | 5 |
6 | 7 | ## 🤝 如何贡献 8 | 9 | 感谢你考虑为 MusicCard 项目做出贡献!以下是参与项目的一些指南。 10 | 11 | ### 提交 Issue 12 | 13 | - 使用 Issue 模板 14 | - 清晰描述问题或建议 15 | - 如果是 bug,请提供复现步骤 16 | - 如果可能,提供截图或录屏 17 | 18 | ### 提交 Pull Request 19 | 20 | 1. Fork 本仓库 21 | 2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`) 22 | 3. 提交你的修改 (`git commit -m 'Add some AmazingFeature'`) 23 | 4. 推送到分支 (`git push origin feature/AmazingFeature`) 24 | 5. 创建一个 Pull Request 25 | 26 | ### 开发指南 27 | 28 | 1. 确保你的代码符合项目的代码风格 29 | 2. 添加必要的测试 30 | 3. 更新相关文档 31 | 4. 确保所有测试通过 32 | 33 | ### 代码规范 34 | 35 | - 使用 TypeScript 36 | - 遵循 ESLint 规则 37 | - 使用 Prettier 格式化代码 38 | - 编写清晰的代码注释 39 | 40 |
41 | 42 |
43 | 44 | ## 🤝 How to Contribute 45 | 46 | Thank you for considering contributing to MusicCard! Here are some guidelines for participating in the project. 47 | 48 | ### Submitting Issues 49 | 50 | - Use the issue template 51 | - Clearly describe the problem or suggestion 52 | - If it's a bug, provide steps to reproduce 53 | - Include screenshots or recordings if possible 54 | 55 | ### Submitting Pull Requests 56 | 57 | 1. Fork the repository 58 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 59 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 60 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 61 | 5. Create a Pull Request 62 | 63 | ### Development Guidelines 64 | 65 | 1. Ensure your code follows the project's style 66 | 2. Add necessary tests 67 | 3. Update relevant documentation 68 | 4. Make sure all tests pass 69 | 70 | ### Code Standards 71 | 72 | - Use TypeScript 73 | - Follow ESLint rules 74 | - Format code with Prettier 75 | - Write clear code comments 76 | 77 |
78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 MusicCard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MusicCard - 音乐海报生成器 | Music Poster Generator 2 | 3 | [English](#english) | [中文](#chinese) 4 | 5 |
6 | 7 | > 灵感来源: 本项目受 [BeatPrints](https://github.com/TrueMyst/BeatPrints) 启发,旨在提供一个现代化的 Web 版本音乐海报生成工具。 8 | 9 | ## 🎵 项目介绍 10 | 11 | MusicCard 是一个基于 Next.js 开发的在线音乐海报生成工具,让用户能够为自己喜爱的网易云音乐创建精美的可视化海报。 12 | 13 | ### ✨ 主要特性 14 | 15 | - 🎨 支持多种海报主题和布局 16 | - 🎵 支持网易云音乐链接解析 17 | - 📱 响应式设计,支持移动端 18 | - 🌈 自定义海报样式和颜色 19 | - 📝 支持歌词展示 20 | - 💾 支持导出高清图片 21 | 22 | ### 🚀 在线体验 23 | 24 | 访问 [MusicCard](https://card.catpng.net) 立即体验 25 | 26 | ### 🛠️ 技术栈 27 | 28 | - Next.js 14 29 | - TypeScript 30 | - Tailwind CSS 31 | - 网易云音乐 API 32 | 33 | ### 📦 本地开发 34 | 35 | 1. 克隆项目 36 | 37 | ```bash 38 | git clone https://github.com/aidaox/MusicCard.git 39 | cd MusicCard 40 | ``` 41 | 42 | 2. 安装依赖 43 | 44 | ```bash 45 | npm install 46 | # 或 47 | yarn install 48 | ``` 49 | 50 | 3. 启动开发服务器 51 | 52 | ```bash 53 | npm run dev 54 | # 或 55 | yarn dev 56 | ``` 57 | 58 | 4. 在浏览器中打开 http://localhost:3000 查看效果 59 | 60 | ### 🚀 部署说明 61 | 62 | 本项目推荐使用 Vercel 部署: 63 | 64 | 1. Fork 本项目到你的 GitHub 账号 65 | 2. 在 [Vercel](https://vercel.com) 上注册账号 66 | 3. 在 Vercel 中导入你 fork 的项目 67 | 4. 点击 "Deploy" 按钮即可完成部署 68 | 69 | ### 🤝 贡献指南 70 | 71 | 欢迎提交 Issue 和 Pull Request 来帮助改进项目! 72 | 73 | ### 📄 开源协议 74 | 75 | 本项目采用 MIT 协议开源。 76 | 77 |
78 | 79 |
80 | > Inspired by [BeatPrints](https://github.com/TrueMyst/BeatPrints), this project aims to provide a modern web-based music poster generation tool. 81 | 82 | 83 | ## 🎵 Introduction 84 | 85 | MusicCard is an online music poster generator built with Next.js, allowing users to create beautiful visual posters for their favorite NetEase Cloud Music tracks. 86 | 87 | ### ✨ Features 88 | 89 | - 🎨 Multiple poster themes and layouts 90 | - 🎵 NetEase Cloud Music link parsing 91 | - 📱 Responsive design 92 | - 🌈 Customizable styles and colors 93 | - 📝 Lyrics display 94 | - 💾 High-quality image export 95 | 96 | ### 🚀 Live Demo 97 | 98 | Visit [MusicCard](https://card.catpng.net) to try it out 99 | 100 | ### 🛠️ Tech Stack 101 | 102 | - Next.js 14 103 | - TypeScript 104 | - Tailwind CSS 105 | - NetEase Cloud Music API 106 | 107 | ### 📦 Local Development 108 | 109 | 1. Clone the repository 110 | 111 | ```bash 112 | git clone https://github.com/aidaox/MusicCard.git 113 | cd MusicCard 114 | ``` 115 | 116 | 2. Install dependencies 117 | 118 | ```bash 119 | npm install 120 | # or 121 | yarn install 122 | ``` 123 | 124 | 3. Start development server 125 | 126 | ```bash 127 | npm run dev 128 | # or 129 | yarn dev 130 | ``` 131 | 132 | 4. Open http://localhost:3000 in your browser to see the result 133 | 134 | ### 🚀 Deployment 135 | 136 | We recommend deploying with Vercel: 137 | 138 | 1. Fork this project to your GitHub account 139 | 2. Sign up for an account on [Vercel](https://vercel.com) 140 | 3. Import your forked project in Vercel 141 | 4. Click "Deploy" to complete the deployment 142 | 143 | ### 🤝 Contributing 144 | 145 | Issues and Pull Requests are welcome! 146 | 147 | ### 📄 License 148 | 149 | This project is licensed under the MIT License. 150 | 151 |
152 | -------------------------------------------------------------------------------- /app/api/music/route.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "@/utils/retry"; 2 | import { NextResponse } from "next/server"; 3 | import { MusicPlatform } from "@/types/music"; 4 | import axios from "axios"; 5 | 6 | // 定义歌手类型 7 | interface Artist { 8 | name: string; 9 | } 10 | 11 | // 网易云音乐API请求头 12 | const NETEASE_HEADERS = { 13 | "User-Agent": 14 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 15 | Referer: "http://music.163.com", 16 | Host: "music.163.com", 17 | }; 18 | 19 | // 缓存音乐信息,减少重复请求 20 | const MUSIC_CACHE = new Map(); 21 | // 缓存过期时间(24小时) 22 | const CACHE_TTL = 24 * 60 * 60 * 1000; 23 | 24 | export async function GET(request: Request) { 25 | try { 26 | // 从URL中获取参数 27 | const { searchParams } = new URL(request.url); 28 | const platform = searchParams.get("platform"); 29 | const musicId = searchParams.get("id"); 30 | 31 | console.log("收到请求:", { platform, musicId }); 32 | 33 | if (!platform || !musicId) { 34 | console.error("缺少必要参数:", { platform, musicId }); 35 | return NextResponse.json({ error: "缺少必要参数" }, { status: 400 }); 36 | } 37 | 38 | // 生成缓存key 39 | const cacheKey = `${platform}:${musicId}`; 40 | 41 | // 检查缓存 42 | if (MUSIC_CACHE.has(cacheKey)) { 43 | const { data, timestamp } = MUSIC_CACHE.get(cacheKey); 44 | // 检查缓存是否过期 45 | if (Date.now() - timestamp < CACHE_TTL) { 46 | console.log("从缓存返回结果:", cacheKey); 47 | return NextResponse.json(data, { 48 | headers: { 49 | "Cache-Control": "public, max-age=3600", 50 | }, 51 | }); 52 | } else { 53 | // 删除过期缓存 54 | MUSIC_CACHE.delete(cacheKey); 55 | } 56 | } 57 | 58 | // 根据平台获取音乐信息 59 | switch (platform) { 60 | case MusicPlatform.NETEASE: { 61 | console.log("开始获取网易云音乐信息:", musicId); 62 | 63 | try { 64 | // 创建取消标记,设置超时 65 | const controller = new AbortController(); 66 | const timeoutId = setTimeout(() => controller.abort(), 8000); 67 | 68 | // 并行请求歌曲详情和歌词,优化加载速度 69 | const [songDetailResponse, lyricsResponse] = await Promise.all([ 70 | // 获取歌曲详情 71 | retry( 72 | async () => { 73 | const response = await axios.get( 74 | `http://music.163.com/api/song/detail/?id=${musicId}&ids=[${musicId}]`, 75 | { 76 | headers: NETEASE_HEADERS, 77 | timeout: 5000, 78 | signal: controller.signal, 79 | } 80 | ); 81 | if (!response.data?.songs?.[0]) { 82 | throw new Error("未找到歌曲信息"); 83 | } 84 | return response.data; 85 | }, 86 | { 87 | maxAttempts: 2, 88 | initialDelay: 500, 89 | maxDelay: 2000, 90 | } 91 | ), 92 | // 获取歌词 93 | retry( 94 | async () => { 95 | const response = await axios.get( 96 | `http://music.163.com/api/song/lyric?id=${musicId}&lv=1&kv=1&tv=-1`, 97 | { 98 | headers: NETEASE_HEADERS, 99 | timeout: 5000, 100 | signal: controller.signal, 101 | } 102 | ); 103 | return response.data; 104 | }, 105 | { 106 | maxAttempts: 2, 107 | initialDelay: 500, 108 | maxDelay: 2000, 109 | } 110 | ), 111 | ]); 112 | 113 | // 清除超时 114 | clearTimeout(timeoutId); 115 | 116 | console.log("歌曲详情和歌词获取完成"); 117 | const song = songDetailResponse.songs[0]; 118 | 119 | // 解析歌词 120 | const lyrics = lyricsResponse.lrc?.lyric || ""; 121 | const lyricsArray = lyrics 122 | .split("\n") 123 | .map((line: string) => 124 | line.replace(/\[\d{2}:\d{2}\.\d{2,3}\]/g, "").trim() 125 | ) 126 | .filter((line: string) => line); 127 | 128 | const result = { 129 | title: song.name, 130 | artist: song.artists.map((a: Artist) => a.name).join(", "), 131 | coverUrl: song.album.picUrl, 132 | lyrics: lyricsArray.join("\n"), 133 | duration: Math.floor(song.duration / 1000), 134 | }; 135 | 136 | // 缓存结果 137 | MUSIC_CACHE.set(cacheKey, { 138 | data: result, 139 | timestamp: Date.now(), 140 | }); 141 | 142 | console.log("处理完成,返回结果:", result); 143 | return NextResponse.json(result, { 144 | headers: { 145 | "Cache-Control": "public, max-age=3600", 146 | }, 147 | }); 148 | } catch (error) { 149 | console.error("网易云音乐API调用失败:", error); 150 | if (axios.isAxiosError(error)) { 151 | console.error("API错误详情:", { 152 | status: error.response?.status, 153 | data: error.response?.data, 154 | headers: error.response?.headers, 155 | }); 156 | 157 | // 处理特定错误 158 | if (error.code === "ECONNABORTED") { 159 | return NextResponse.json( 160 | { error: "网易云音乐API请求超时,请稍后重试" }, 161 | { status: 504 } 162 | ); 163 | } 164 | } 165 | throw error; 166 | } 167 | } 168 | 169 | default: 170 | console.error("不支持的音乐平台:", platform); 171 | return NextResponse.json( 172 | { error: "不支持的音乐平台" }, 173 | { status: 400 } 174 | ); 175 | } 176 | } catch (error) { 177 | console.error("获取音乐信息失败:", error); 178 | if (axios.isAxiosError(error)) { 179 | console.error("错误详情:", { 180 | status: error.response?.status, 181 | data: error.response?.data, 182 | headers: error.response?.headers, 183 | }); 184 | } 185 | return NextResponse.json( 186 | { 187 | error: "获取音乐信息失败", 188 | details: error instanceof Error ? error.message : String(error), 189 | }, 190 | { status: 500 } 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /app/api/proxy/image/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/proxy/image/route.ts 2 | // 图片代理 API 路由,用于获取跨域图片 3 | 4 | import { NextResponse } from "next/server"; 5 | 6 | // 处理 GET 请求 7 | export async function GET(request: Request) { 8 | try { 9 | // 从 URL 参数中获取目标图片 URL 10 | const url = new URL(request.url); 11 | const imageUrl = url.searchParams.get("url"); 12 | 13 | if (!imageUrl) { 14 | return new NextResponse("Missing image URL", { status: 400 }); 15 | } 16 | 17 | // 获取图片 18 | const response = await fetch(imageUrl, { 19 | // 添加超时设置,避免请求卡住 20 | signal: AbortSignal.timeout(5000), 21 | // 添加请求头,模拟浏览器行为,减少被拒绝的可能性 22 | headers: { 23 | "User-Agent": 24 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 25 | Referer: "http://music.163.com", 26 | }, 27 | }); 28 | 29 | if (!response.ok) { 30 | console.error( 31 | `图片代理请求失败: ${imageUrl}, 状态码: ${response.status}` 32 | ); 33 | return new NextResponse("Failed to fetch image", { 34 | status: response.status, 35 | }); 36 | } 37 | 38 | // 获取图片数据 39 | const imageBuffer = await response.arrayBuffer(); 40 | const contentType = response.headers.get("content-type") || "image/jpeg"; 41 | 42 | // 返回图片,设置正确的 content-type 和 CORS 头 43 | return new NextResponse(imageBuffer, { 44 | headers: { 45 | "Content-Type": contentType, 46 | // 增强缓存策略,设置更长的缓存时间和验证机制 47 | "Cache-Control": 48 | "public, max-age=31536000, stale-while-revalidate=86400", 49 | ETag: `"${Buffer.from(imageUrl).toString("base64").substring(0, 16)}"`, 50 | "Access-Control-Allow-Origin": "*", 51 | }, 52 | }); 53 | } catch (error) { 54 | console.error("Error proxying image:", error); 55 | // 如果是超时错误,返回特定的错误信息 56 | if (error instanceof DOMException && error.name === "TimeoutError") { 57 | return new NextResponse("Image request timed out", { status: 504 }); 58 | } 59 | return new NextResponse("Error proxying image", { status: 500 }); 60 | } 61 | } 62 | 63 | // 处理预检请求 64 | export async function OPTIONS() { 65 | return new NextResponse(null, { 66 | headers: { 67 | "Access-Control-Allow-Origin": "*", 68 | "Access-Control-Allow-Methods": "GET, OPTIONS", 69 | "Access-Control-Allow-Headers": "Content-Type", 70 | "Access-Control-Max-Age": "86400", 71 | }, 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /app/api/resolve-url/route.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "@/utils/retry"; 2 | 3 | // 从文本中提取URL的函数 4 | function extractUrl(text: string): string | null { 5 | const urlRegex = /(https?:\/\/[^\s]+)/g; 6 | const matches = text.match(urlRegex); 7 | return matches ? matches[0] : null; 8 | } 9 | 10 | export async function GET(request: Request) { 11 | try { 12 | const { searchParams } = new URL(request.url); 13 | let url = searchParams.get("url"); 14 | 15 | if (!url) { 16 | return new Response("URL参数不能为空", { status: 400 }); 17 | } 18 | 19 | // 从文本中提取URL 20 | const extractedUrl = extractUrl(url); 21 | if (!extractedUrl) { 22 | return new Response("无法从文本中提取URL", { status: 400 }); 23 | } 24 | url = extractedUrl; 25 | 26 | // 使用重试函数发起请求 27 | const response = await retry( 28 | async () => { 29 | const res = await fetch(url!, { 30 | redirect: "follow", 31 | headers: { 32 | "User-Agent": 33 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 34 | }, 35 | }); 36 | 37 | if (!res.ok) { 38 | throw new Error(`HTTP error! status: ${res.status}`); 39 | } 40 | 41 | return res; 42 | }, 43 | { 44 | maxAttempts: 3, 45 | initialDelay: 1000, 46 | maxDelay: 5000, 47 | } 48 | ); 49 | 50 | // 获取最终URL 51 | const finalUrl = response.url; 52 | 53 | return new Response(JSON.stringify({ url: finalUrl }), { 54 | headers: { "Content-Type": "application/json" }, 55 | }); 56 | } catch (error) { 57 | console.error("解析URL失败:", error); 58 | return new Response( 59 | JSON.stringify({ 60 | error: error instanceof Error ? error.message : "解析URL失败", 61 | }), 62 | { 63 | status: 500, 64 | headers: { "Content-Type": "application/json" }, 65 | } 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | 3 | export const inter = Inter({ 4 | subsets: ["latin"], 5 | display: "swap", 6 | variable: "--font-inter", 7 | fallback: [ 8 | "-apple-system", 9 | "BlinkMacSystemFont", 10 | "Segoe UI", 11 | "Roboto", 12 | "Helvetica Neue", 13 | "Arial", 14 | "sans-serif", 15 | ], 16 | preload: true, 17 | adjustFontFallback: true, 18 | }); 19 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | /* app/globals.css */ 2 | /* 此文件定义了全局样式和组件样式 3 | /* 包括: 4 | /* 1. Material Icons 字体 5 | /* 2. 基础样式(body等) 6 | /* 3. 播放器组件样式 7 | /* 4. 进度条和音量控制样式 8 | /* 5. 按钮和图标样式 9 | */ 10 | 11 | /* Material Icons */ 12 | @import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"); 13 | 14 | @tailwind base; 15 | @tailwind components; 16 | @tailwind utilities; 17 | 18 | @layer base { 19 | body { 20 | margin: 0; 21 | font-family: Arial, sans-serif; 22 | background-color: #f0f0f0; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | min-height: 100vh; 27 | } 28 | } 29 | 30 | @layer components { 31 | .music-player { 32 | width: 550px; 33 | background-color: #3c3c3c; 34 | border-radius: 45px; 35 | padding: 20px; 36 | box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2); 37 | color: #ffffff; 38 | display: flex; 39 | flex-direction: column; 40 | justify-content: space-between; 41 | height: 870px; 42 | align-items: center; 43 | } 44 | 45 | .music-cover { 46 | width: 90%; 47 | border-radius: 15px; 48 | overflow: hidden; 49 | margin-top: 30px; 50 | aspect-ratio: 1; 51 | } 52 | 53 | .music-cover img { 54 | width: 100%; 55 | height: 100%; 56 | border-radius: 15px; 57 | object-fit: cover; 58 | } 59 | 60 | .music-info { 61 | width: 90%; 62 | text-align: left; 63 | margin: 30px 0; 64 | } 65 | 66 | .music-info h2 { 67 | margin: 0; 68 | font-size: 25px; 69 | font-weight: bold; 70 | white-space: nowrap; 71 | overflow: hidden; 72 | text-overflow: ellipsis; 73 | } 74 | 75 | .music-info p { 76 | margin: 5px 0 0; 77 | color: #c6c7c9; 78 | font-size: 20px; 79 | white-space: nowrap; 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | } 83 | 84 | .progress-container { 85 | display: flex; 86 | align-items: center; 87 | justify-content: space-between; 88 | margin: 15px 0; 89 | width: 90%; 90 | position: relative; 91 | gap: 15px; 92 | } 93 | 94 | .progress-bar, 95 | .volume-bar { 96 | -webkit-appearance: none; 97 | appearance: none; 98 | height: 12px; 99 | background-color: #c6c7c9; 100 | outline: none; 101 | border-radius: 10px; 102 | margin: 0; 103 | flex: 1; 104 | } 105 | 106 | .time-info { 107 | font-size: 16px; 108 | color: #cfcfcf; 109 | min-width: 45px; 110 | text-align: center; 111 | } 112 | 113 | .time-info span { 114 | display: inline-block; 115 | } 116 | 117 | .progress-bar::-webkit-slider-runnable-track, 118 | .volume-bar::-webkit-slider-runnable-track { 119 | height: 12px; 120 | border-radius: 12px; 121 | background: transparent; 122 | } 123 | 124 | .progress-bar::-webkit-slider-thumb, 125 | .volume-bar::-webkit-slider-thumb { 126 | -webkit-appearance: none; 127 | appearance: none; 128 | width: 12px; 129 | height: 12px; 130 | background-color: #dfe3e2; 131 | cursor: pointer; 132 | border-radius: 50%; 133 | position: relative; 134 | z-index: 2; 135 | margin-top: 0; 136 | } 137 | 138 | .progress-bar::-moz-range-thumb, 139 | .volume-bar::-moz-range-thumb { 140 | width: 12px; 141 | height: 12px; 142 | background-color: #dfe3e2; 143 | cursor: pointer; 144 | border-radius: 50%; 145 | border: none; 146 | } 147 | 148 | .volume-icon { 149 | font-family: "Material Symbols Outlined"; 150 | font-size: 24px; 151 | color: #c6c7c9; 152 | display: flex; 153 | align-items: center; 154 | justify-content: center; 155 | width: 45px; 156 | flex-shrink: 0; 157 | } 158 | 159 | .controls { 160 | display: flex; 161 | flex-direction: column; 162 | align-items: center; 163 | width: 90%; 164 | margin: 15px 0; 165 | } 166 | 167 | .playback-buttons { 168 | display: flex; 169 | justify-content: space-between; 170 | align-items: center; 171 | width: 100%; 172 | gap: 30px; 173 | } 174 | 175 | .playback-buttons button { 176 | background: none; 177 | border: none; 178 | color: #fff; 179 | cursor: pointer; 180 | padding: 10px; 181 | display: flex; 182 | align-items: center; 183 | justify-content: center; 184 | flex: 1; 185 | } 186 | 187 | .playback-buttons button:hover { 188 | color: #000; 189 | } 190 | 191 | .playback-buttons button svg { 192 | width: 32px; 193 | height: 32px; 194 | } 195 | 196 | /* 播放按钮特殊处理 */ 197 | .playback-buttons button:nth-child(2) { 198 | flex: 1.2; 199 | } 200 | .playback-buttons button:nth-child(2) svg { 201 | width: 40px; 202 | height: 40px; 203 | } 204 | 205 | /* 音量控制容器特殊处理 */ 206 | .progress-container:last-child { 207 | width: 90%; 208 | padding: 0; 209 | gap: 0; 210 | } 211 | 212 | /* 音量条特殊处理 */ 213 | .progress-container:last-child .volume-bar { 214 | margin: 0 15px; 215 | } 216 | } 217 | 218 | /* 编辑相关样式 */ 219 | .edit-overlay { 220 | position: fixed; 221 | top: 0; 222 | left: 0; 223 | right: 0; 224 | bottom: 0; 225 | background: rgba(0, 0, 0, 0.5); 226 | display: flex; 227 | align-items: center; 228 | justify-content: center; 229 | z-index: 50; 230 | } 231 | 232 | .edit-panel { 233 | background: white; 234 | padding: 2rem; 235 | border-radius: 0.5rem; 236 | width: 100%; 237 | max-width: 32rem; 238 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 239 | 0 2px 4px -1px rgba(0, 0, 0, 0.06); 240 | } 241 | 242 | /* 输入框样式 */ 243 | .input-field { 244 | width: 100%; 245 | padding: 0.5rem; 246 | border: 1px solid #e5e7eb; 247 | border-radius: 0.375rem; 248 | margin-bottom: 1rem; 249 | } 250 | 251 | .input-field:focus { 252 | outline: none; 253 | border-color: #6366f1; 254 | box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); 255 | } 256 | 257 | /* 按钮样式 */ 258 | .btn { 259 | padding: 0.5rem 1rem; 260 | border-radius: 0.375rem; 261 | font-weight: 500; 262 | transition: all 0.2s; 263 | } 264 | 265 | .btn-primary { 266 | background-color: #6366f1; 267 | color: white; 268 | } 269 | 270 | .btn-primary:hover { 271 | background-color: #4f46e5; 272 | } 273 | 274 | .btn-secondary { 275 | background-color: #9ca3af; 276 | color: white; 277 | } 278 | 279 | .btn-secondary:hover { 280 | background-color: #6b7280; 281 | } 282 | 283 | /* 滑块样式 */ 284 | .range-slider { 285 | width: 100%; 286 | height: 4px; 287 | background: #e5e7eb; 288 | border-radius: 2px; 289 | outline: none; 290 | -webkit-appearance: none; 291 | } 292 | 293 | .range-slider::-webkit-slider-thumb { 294 | -webkit-appearance: none; 295 | width: 16px; 296 | height: 16px; 297 | background: #6366f1; 298 | border-radius: 50%; 299 | cursor: pointer; 300 | } 301 | 302 | /* 文件上传样式 */ 303 | .file-input { 304 | width: 100%; 305 | padding: 0.5rem; 306 | border: 1px dashed #e5e7eb; 307 | border-radius: 0.375rem; 308 | background: #f9fafb; 309 | cursor: pointer; 310 | } 311 | 312 | .file-input:hover { 313 | border-color: #6366f1; 314 | background: #f3f4f6; 315 | } 316 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import Script from "next/script"; 4 | import { inter } from "./fonts"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Card.Catpng.net - 音乐卡片生成器", 8 | description: "为你喜欢的音乐生成精美卡片", 9 | icons: { 10 | shortcut: ["/favicon.ico"], 11 | }, 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 26 | {/* Google Analytics 代码 */} 27 |