diff --git a/bun.lock b/bun.lock index 482c6f9..280b910 100644 --- a/bun.lock +++ b/bun.lock @@ -91,7 +91,7 @@ "@elysiajs/server-timing": "^1.4.0", "@rabbit-company/argon2id": "^2.1.0", "chalk": "^5.6.2", - "elysia": "^1.4.15", + "elysia": "catalog:", "elysia-ip": "^1.0.10", "zod": "^4.1.12", }, @@ -217,6 +217,7 @@ "sonner": "^2.0.7", "swr": "^2.3.6", "tailwind-merge": "^3.3.1", + "zod": "^4.1.12", }, "devDependencies": { "@react-router/dev": "^7.7.1", @@ -233,6 +234,9 @@ }, }, }, + "catalog": { + "elysia": "^1.4.15", + }, "packages": { "@alicloud/credentials": ["@alicloud/credentials@2.4.4", "", { "dependencies": { "@alicloud/tea-typescript": "^1.8.0", "httpx": "^2.3.3", "ini": "^1.3.5", "kitx": "^2.0.0" } }, "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q=="], @@ -2970,6 +2974,8 @@ "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cvsa/cvsa-temp/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "@deno/shim-deno/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], diff --git a/package.json b/package.json index f5b6da0..c8ae31c 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,21 @@ "version": "3.15.34", "private": false, "type": "module", - "workspaces": [ - "packages/frontend", - "packages/core", - "packages/backend", - "packages/crawler", - "packages/elysia", - "packages/temp_frontend", - "packages/solid", - "packages/palette" - ], + "workspaces": { + "packages": [ + "packages/frontend", + "packages/core", + "packages/backend", + "packages/crawler", + "packages/elysia", + "packages/temp_frontend", + "packages/solid", + "packages/palette" + ], + "catalog": { + "elysia": "^1.4.15" + } + }, "dependencies": { "arg": "^5.0.2", "dotenv": "^17.2.3", diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 3acf854..a514664 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -14,7 +14,7 @@ "@elysiajs/server-timing": "^1.4.0", "@rabbit-company/argon2id": "^2.1.0", "chalk": "^5.6.2", - "elysia": "^1.4.15", + "elysia": "catalog:", "elysia-ip": "^1.0.10", "zod": "^4.1.12" }, diff --git a/packages/elysia/routes/auth/login.ts b/packages/elysia/routes/auth/login.ts index 3cd41ea..dedf2c4 100644 --- a/packages/elysia/routes/auth/login.ts +++ b/packages/elysia/routes/auth/login.ts @@ -32,7 +32,8 @@ export const loginHandler = new Elysia({ prefix: "/auth" }) username: user.username, nickname: user.nickname, role: user.role - } + }, + sessionID: sessionId }; }, { @@ -44,7 +45,8 @@ export const loginHandler = new Elysia({ prefix: "/auth" }) username: t.String(), nickname: t.Optional(t.String()), role: t.String() - }) + }), + sessionID: t.String() }), 401: t.Object({ message: t.String() diff --git a/packages/elysia/routes/search/index.ts b/packages/elysia/routes/search/index.ts index 4cea4c3..8227740 100644 --- a/packages/elysia/routes/search/index.ts +++ b/packages/elysia/routes/search/index.ts @@ -105,6 +105,7 @@ const BiliVideoDataSchema = BiliVideoSchema.extend({ export const searchHandler = new Elysia({ prefix: "/search" }).get( "/result", async ({ query }) => { + const start = performance.now(); const searchQuery = query.query; const [songResults, videoResults] = await Promise.all([ getSongSearchResult(searchQuery), @@ -112,24 +113,32 @@ export const searchHandler = new Elysia({ prefix: "/search" }).get( ]); const combinedResults = [...songResults, ...videoResults]; - return combinedResults.sort((a, b) => b.rank - a.rank); + const data = combinedResults.sort((a, b) => b.rank - a.rank); + const end = performance.now(); + return { + data, + elapsedMs: end - start + }; }, { response: { - 200: z.array( - z.union([ - z.object({ - type: z.literal("song"), - data: SongSchema, - rank: z.number() - }), - z.object({ - type: z.literal("bili-video"), - data: BiliVideoDataSchema, - rank: z.number() - }) - ]) - ), + 200: z.object({ + elapsedMs: z.number(), + data: z.array( + z.union([ + z.object({ + type: z.literal("song"), + data: SongSchema, + rank: z.number() + }), + z.object({ + type: z.literal("bili-video"), + data: BiliVideoDataSchema, + rank: z.number() + }) + ]) + ) + }), 404: z.object({ message: z.string() }) diff --git a/packages/elysia/routes/song/info.ts b/packages/elysia/routes/song/info.ts index 8336691..8280edc 100644 --- a/packages/elysia/routes/song/info.ts +++ b/packages/elysia/routes/song/info.ts @@ -98,9 +98,13 @@ const songInfoGetHandler = new Elysia({ prefix: "/song" }).get( cover: t.Optional(t.String()) }), 404: t.Object({ + code: t.String(), message: t.String() }) }, + headers: t.Object({ + "Authorization": t.Optional(t.String()) + }), detail: { summary: "Get information of a song", description: diff --git a/packages/temp_frontend/app/components/Error.tsx b/packages/temp_frontend/app/components/Error.tsx index 0855ee8..c06ac6c 100644 --- a/packages/temp_frontend/app/components/Error.tsx +++ b/packages/temp_frontend/app/components/Error.tsx @@ -5,7 +5,8 @@ export function Error({ error }: { error: { status: number; value: { message?: s return (
- <div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center"> + <div className="max-w-md w-full mx-4 bg-gray-100 dark:bg-neutral-900 rounded-2xl + shadow-lg p-6 flex flex-col gap-4 items-center text-center"> <div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl"> <TriangleAlert size={34} className="-translate-y-0.5" /> </div> diff --git a/packages/temp_frontend/app/components/Layout.tsx b/packages/temp_frontend/app/components/Layout.tsx index 7ae8a5f..7fe4d52 100644 --- a/packages/temp_frontend/app/components/Layout.tsx +++ b/packages/temp_frontend/app/components/Layout.tsx @@ -1,12 +1,18 @@ +import { toast } from "sonner"; import { Search } from "./Search"; export function Layout({ children }: { children?: React.ReactNode }) { return ( <div className="w-screen min-h-screen relative left-0 top-0 flex justify-center"> - <main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl"> - <a href="/"> - <h1 className="text-4xl mb-5">中V档案馆</h1> - </a> + <main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl"> + <div className="flex items-center justify-between"> + <a href="/"> + <h1 className="text-3xl mb-5">中V档案馆</h1> + </a> + <div className="h-8"> + <LoginOrLogout /> + </div> + </div> <Search /> {children} </main> @@ -14,13 +20,37 @@ export function Layout({ children }: { children?: React.ReactNode }) { ); } -export function LayoutWithouSearch({ children }: { children?: React.ReactNode }) { +const LoginOrLogout = () => { + const session = localStorage.getItem("sessionID"); + if (session) { + return ( + <span + onClick={() => { + localStorage.removeItem("sessionID"); + toast.success("已退出登录"); + }} + > + 退出登录 + </span> + ); + } else { + return <a href="/login">登录</a>; + } +}; + +export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) { return ( <div className="w-screen min-h-screen relative left-0 top-0 flex justify-center"> - <main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl"> - <a href="/"> - <h1 className="text-4xl mb-5">中V档案馆</h1> - </a> + <main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl"> + <div className="flex items-center justify-between"> + <a href="/"> + <h1 className="text-3xl mb-5">中V档案馆</h1> + </a> + <div className="h-8"> + <LoginOrLogout /> + </div> + </div> + {children} </main> </div> diff --git a/packages/temp_frontend/app/components/SearchResults.tsx b/packages/temp_frontend/app/components/SearchResults.tsx index 4aef7ca..1d34f52 100644 --- a/packages/temp_frontend/app/components/SearchResults.tsx +++ b/packages/temp_frontend/app/components/SearchResults.tsx @@ -6,7 +6,7 @@ interface SearchResultsProps { query: string; } -const formatDateTime = (date: Date): string => { +export const formatDateTime = (date: Date): string => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); // 月份从0开始,补0 const day = String(date.getDate()).padStart(2, "0"); @@ -20,7 +20,7 @@ const formatDateTime = (date: Date): string => { const biliIDSchema = z.union([z.string().regex(/BV1[0-9A-Za-z]{9}/), z.string().regex(/av[0-9]+/)]); export function SearchResults({ results, query }: SearchResultsProps) { - if (!results || results.length === 0) { + if (!results || results.data.length === 0) { if (!biliIDSchema.safeParse(query).success) { return ( <div className="text-center pt-6"> @@ -43,10 +43,12 @@ export function SearchResults({ results, query }: SearchResultsProps) { return ( <div className="space-y-4 mb-20 mt-5"> - {results.map((result, index) => ( + <p>找到 {results.data.length} 个结果({(results.elapsedMs / 1000).toFixed(3)}秒):</p> + {results.data.map((result, index) => ( <div key={`${result.type}-${result.data.id}-${index}`} - className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700 p-4 hover:shadow-md transition-shadow" + className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border + border-gray-200 dark:border-neutral-700 p-2 sm:p-4 hover:shadow-md transition-shadow" > {result.type === "song" ? <SongResult result={result} /> : <BiliVideoResult result={result} />} </div> @@ -55,22 +57,22 @@ export function SearchResults({ results, query }: SearchResultsProps) { ); } -function SongResult({ result }: { result: Exclude<SearchResult, null>[number] }) { +function SongResult({ result }: { result: Exclude<SearchResult, null>["data"][number] }) { if (result.type !== "song") return null; const { data } = result; return ( - <div className="flex items-start space-x-4"> + <div className="flex items-center space-x-4"> {data.image && ( <img src={data.image} alt="歌曲封面" - className="w-42 h-24 rounded-sm object-cover flex-shrink-0" + className="h-21 w-36 sm:w-42 sm:h-24 rounded-sm object-cover flex-shrink-0" referrerPolicy="no-referrer" /> )} - <div className="flex-1 min-w-0"> - <h3 className="text-lg font-medium truncate">{data.name}</h3> + <div className="flex-col"> + <h3 className="text-lg font-medium line-clamp-1 text-wrap">{data.name}</h3> {data.producer && <p className="text-sm text-muted-foreground truncate">{data.producer}</p>} <div className="flex items-center space-x-4 my-1 text-xs text-muted-foreground"> {data.duration && ( @@ -95,7 +97,7 @@ function SongResult({ result }: { result: Exclude<SearchResult, null>[number] }) ); } -function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>[number] }) { +function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>["data"][number] }) { if (result.type !== "bili-video") return null; const { data } = result; @@ -116,7 +118,7 @@ function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>[numbe {data.description} </pre> )} - <div className="flex items-center space-x-4 my-2 text-xs text-gray-500 dark:text-gray-500"> + <div className="grid-cols-2 sm:flex items-center space-x-4 my-2 text-xs text-gray-500 dark:text-gray-500"> {data.duration && ( <span> {Math.floor(data.duration / 60)}:{(data.duration % 60).toString().padStart(2, "0")} diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index c0e4558..557c9ed 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -5,4 +5,5 @@ export default [ route("song/:id/info", "routes/song/[id]/info.tsx"), route("chart-demo", "routes/chartDemo.tsx"), route("search", "routes/search/index.tsx"), + route("login", "routes/login.tsx"), ] satisfies RouteConfig; diff --git a/packages/temp_frontend/app/routes/login.tsx b/packages/temp_frontend/app/routes/login.tsx new file mode 100644 index 0000000..bf8f4ea --- /dev/null +++ b/packages/temp_frontend/app/routes/login.tsx @@ -0,0 +1,99 @@ +import { LayoutWithoutSearch } from "@/components/Layout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { treaty } from "@elysiajs/eden"; +import type { App } from "@elysia/src"; + +// @ts-expect-error anyway... +const app = treaty<App>(import.meta.env.VITE_API_URL!); + +export default function Login() { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + username: "", + password: "", + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + setError(""); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + + try { + const { data, error } = await app.auth.session.post(formData); + + if (data) { + localStorage.setItem("sessionID", data.sessionID); + navigate("/", { replace: true }); + } else { + setError(error?.value?.message || "登录失败"); + } + } catch (err) { + setError("网络错误,请稍后重试"); + console.error("Login error:", err); + } finally { + setIsLoading(false); + } + }; + + return ( + <LayoutWithoutSearch> + <h2 className="text-2xl font-bold mb-6">登录</h2> + + {error && ( + <div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 rounded"> + {error} + </div> + )} + + <form onSubmit={handleSubmit} className="space-y-4 flex flex-col"> + <div> + <label htmlFor="username" className="block text-sm font-medium mb-2"> + 用户名 + </label> + <Input + type="text" + id="username" + name="username" + value={formData.username} + onChange={handleChange} + required + placeholder="请输入用户名" + /> + </div> + + <div> + <label htmlFor="password" className="block text-sm font-medium mb-2"> + 密码 + </label> + <Input + type="password" + id="password" + name="password" + value={formData.password} + onChange={handleChange} + required + placeholder="请输入密码" + /> + </div> + + <Button type="submit" disabled={isLoading}> + {isLoading ? "登录中..." : "登录"} + </Button> + </form> + </LayoutWithoutSearch> + ); +} diff --git a/packages/temp_frontend/app/routes/search/index.tsx b/packages/temp_frontend/app/routes/search/index.tsx index ef7e7da..8f33250 100644 --- a/packages/temp_frontend/app/routes/search/index.tsx +++ b/packages/temp_frontend/app/routes/search/index.tsx @@ -7,8 +7,9 @@ import { useSearchParams } from "react-router"; import { SearchBox } from "@/components/Search"; import { SearchResults } from "@/components/SearchResults"; import { Title } from "@/components/Title"; -import { LayoutWithouSearch } from "@/components/Layout"; +import { LayoutWithoutSearch } from "@/components/Layout"; +// @ts-expect-error anyway... const app = treaty<App>(import.meta.env.VITE_API_URL!); export type SearchResult = Awaited<ReturnType<typeof app.search.result.get>>["data"]; @@ -57,19 +58,19 @@ export default function SearchResult() { if (!searchParams.get("q")) { return ( - <LayoutWithouSearch> + <LayoutWithoutSearch> <Search query={query} setQuery={setQuery} onSearch={handleSearch} /> - </LayoutWithouSearch> + </LayoutWithoutSearch> ); } if (!data && !error) { return ( - <LayoutWithouSearch> + <LayoutWithoutSearch> <Title title={searchParams.get("q") || "搜索"} /> <Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" /> <Skeleton className="w-full h-24 mb-2" /> - </LayoutWithouSearch> + </LayoutWithoutSearch> ); } @@ -78,10 +79,10 @@ export default function SearchResult() { } return ( - <LayoutWithouSearch> + <LayoutWithoutSearch> <Title title={searchParams.get("q") || ""} /> <Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" /> <SearchResults results={data} query={query} /> - </LayoutWithouSearch> + </LayoutWithoutSearch> ); } diff --git a/packages/temp_frontend/app/routes/song/[id]/info.tsx b/packages/temp_frontend/app/routes/song/[id]/info.tsx index b1bd164..4e522b9 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info.tsx @@ -5,13 +5,15 @@ import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { TriangleAlert } from "lucide-react"; import { Title } from "@/components/Title"; -import { Search } from "@/components/Search"; +import { toast } from "sonner"; import { Error } from "@/components/Error"; import { Layout } from "@/components/Layout"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { formatDateTime } from "@/components/SearchResults"; +// @ts-expect-error anyway... const app = treaty<App>(import.meta.env.VITE_API_URL!); type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"]; @@ -27,14 +29,17 @@ const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => { if (!snapshots) { return ( <> - <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> - <Skeleton className="w-full h-20 rounded-lg" /> + {/* <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> */} + <Skeleton className="w-full h-5 rounded-lg mt-4" /> </> ); } return ( - <div> - <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> + <div className="mt-4"> + <p> + 播放: {snapshots[0].views.toLocaleString()},更新于{formatDateTime(new Date(snapshots[0].createdAt))} + </p> + {/* <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> <table> <thead> <tr> @@ -58,7 +63,7 @@ const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => { </tr> ))} </tbody> - </table> + </table> */} </div> ); }; @@ -143,73 +148,75 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) { } const formatDuration = (duration: number) => { - const minutes = Math.floor(duration / 60); - const seconds = duration % 60; - return `${minutes}:${seconds}`; + return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`; }; const handleSongNameChange = async () => { if (songName.trim() === "") return; - await app.song({ id: loaderData.id }).info.patch({ name: songName }); + const { data, error } = await app.song({ id: loaderData.id }).info.patch( + { name: songName }, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }, + ); setIsDialogOpen(false); // Refresh the data to show the updated name - getInfo(); + if (error || !data) { + toast.error(`无法更新:${error.value.message || "未知错误"}`); + } + setData(data?.updated); }; return ( - <div className="w-screen min-h-screen relative left-0 top-0 flex justify-center"> + <Layout> <Title title={data!.name ? data!.name : "未知歌曲名"} /> - <main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl"> - <a href="/"> - <h1 className="text-4xl mb-5">中V档案馆</h1> - </a> - <Search /> - {data!.cover && ( - <img - src={data!.cover} - referrerPolicy="no-referrer" - className="w-full aspect-video object-cover rounded-lg mt-6" - /> - )} - <div className="mt-6 flex justify-between"> - <div className="flex items-center gap-2"> - <h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}> - {data!.name ? data!.name : "未知歌曲名"} - </h1> - <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> - <DialogContent> - <DialogHeader> - <DialogTitle>编辑歌曲名称</DialogTitle> - </DialogHeader> - <div className="space-y-4"> - <Input - value={songName} - onChange={(e) => setSongName(e.target.value)} - placeholder="请输入歌曲名称" - className="w-full" - /> - <div className="flex justify-end gap-2"> - <Button variant="outline" onClick={() => setIsDialogOpen(false)}> - 取消 - </Button> - <Button onClick={handleSongNameChange}>保存</Button> - </div> + {data!.cover && ( + <img + src={data!.cover} + referrerPolicy="no-referrer" + className="w-full aspect-video object-cover rounded-lg mt-6" + /> + )} + <div className="mt-6 flex justify-between"> + <div className="flex items-center gap-2"> + <h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}> + {data!.name ? data!.name : "未知歌曲名"} + </h1> + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>编辑歌曲名称</DialogTitle> + </DialogHeader> + <div className="space-y-4"> + <Input + value={songName} + onChange={(e) => setSongName(e.target.value)} + placeholder="请输入歌曲名称" + className="w-full" + /> + <div className="flex justify-end gap-2"> + <Button variant="outline" onClick={() => setIsDialogOpen(false)}> + 取消 + </Button> + <Button onClick={handleSongNameChange}>保存</Button> </div> - </DialogContent> - </Dialog> - </div> - <div className="flex flex-col items-end h-10 whitespace-nowrap"> - <span className="leading-5 text-neutral-800 dark:text-neutral-200"> - {data!.duration ? formatDuration(data!.duration) : "未知时长"} - </span> - <span className="text-lg leading-5 text-neutral-800 dark:text-neutral-200 font-bold"> - {data!.producer ? data!.producer : "未知P主"} - </span> - </div> + </div> + </DialogContent> + </Dialog> </div> - <SnapshotsView snapshots={snapshots} /> - </main> - </div> + <div className="flex flex-col items-end h-10 whitespace-nowrap"> + <span className="leading-5 text-neutral-800 dark:text-neutral-200"> + {data!.duration ? formatDuration(data!.duration) : "未知时长"} + </span> + <span className="text-lg leading-5 text-neutral-800 dark:text-neutral-200 font-bold"> + {data!.producer ? data!.producer : "未知P主"} + </span> + </div> + </div> + <SnapshotsView snapshots={snapshots} /> + </Layout> ); } diff --git a/packages/temp_frontend/package.json b/packages/temp_frontend/package.json index 3c1f361..73ee2ac 100644 --- a/packages/temp_frontend/package.json +++ b/packages/temp_frontend/package.json @@ -29,7 +29,8 @@ "recharts": "^3.2.1", "sonner": "^2.0.7", "swr": "^2.3.6", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zod": "^4.1.12" }, "devDependencies": { "@react-router/dev": "^7.7.1",