1
0

add: login page in temp_frontend, improved UI

This commit is contained in:
alikia2x (寒寒) 2025-11-09 07:02:43 +08:00
parent 35749c65a3
commit bf159dcbd3
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
14 changed files with 288 additions and 120 deletions

View File

@ -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=="],

View File

@ -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",

View File

@ -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"
},

View File

@ -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()

View File

@ -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()
})

View File

@ -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:

View File

@ -5,7 +5,8 @@ export function Error({ error }: { error: { status: number; value: { message?: s
return (
<div className="w-screen min-h-screen flex items-center justify-center">
<Title title="出错了" />
<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>

View File

@ -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>

View File

@ -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")}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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",