's'
This commit is contained in:
85
app/vip/components/Copyable.tsx
Normal file
85
app/vip/components/Copyable.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, opacity: 0.6 }}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, color: "var(--accent)" }}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
type CopyableProps = {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export function Copyable({ text, children, className, style }: CopyableProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
const fallbackCopy = () => {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
ta.style.top = "0";
|
||||
ta.setAttribute("readonly", "");
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, text.length);
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
fallbackCopy();
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
try {
|
||||
fallbackCopy();
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleClick()}
|
||||
className={className}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
...style,
|
||||
}}
|
||||
title={copied ? "已复制" : "点击复制"}
|
||||
>
|
||||
{children}
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { WelcomeBlock } from "./dashboard/WelcomeBlock";
|
||||
import { UnlockedStats } from "./dashboard/UnlockedStats";
|
||||
import { RecentUpdates } from "./dashboard/RecentUpdates";
|
||||
import { ContinueLearning } from "./dashboard/ContinueLearning";
|
||||
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
|
||||
import { CommunityHighlights } from "./dashboard/CommunityHighlights";
|
||||
import { WeeklyActions } from "./dashboard/WeeklyActions";
|
||||
|
||||
type DashboardPageProps = {
|
||||
userName: string | null;
|
||||
@@ -17,11 +13,7 @@ export function DashboardPage({ userName }: DashboardPageProps) {
|
||||
<>
|
||||
<WelcomeBlock userName={userName} />
|
||||
<UnlockedStats />
|
||||
<ContinueLearning />
|
||||
<RecentUpdates />
|
||||
<TopicEbookShowcase />
|
||||
<CommunityHighlights />
|
||||
<WeeklyActions />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||
import { Copyable } from "./Copyable";
|
||||
import { pbLogout } from "@/app/lib/pocketbase";
|
||||
import styles from "./vip.module.css";
|
||||
|
||||
function getAvatarLetter(email: string): string {
|
||||
const local = email.split("@")[0];
|
||||
if (!local) return "?";
|
||||
return local.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
type VipHeaderProps = {
|
||||
isLoggedIn: boolean;
|
||||
userName?: string | null;
|
||||
userEmail?: string | null;
|
||||
onLogout?: () => void;
|
||||
};
|
||||
|
||||
export function VipHeader({ isLoggedIn, userName, onLogout }: VipHeaderProps) {
|
||||
export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await pbLogout();
|
||||
setOpen(false);
|
||||
onLogout?.();
|
||||
};
|
||||
|
||||
const showUser = isLoggedIn || userEmail;
|
||||
const displayName = userName || userEmail || "会员";
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Link
|
||||
@@ -23,19 +53,34 @@ export function VipHeader({ isLoggedIn, userName, onLogout }: VipHeaderProps) {
|
||||
</Link>
|
||||
<nav className={styles.nav}>
|
||||
<ThemeToggle />
|
||||
{isLoggedIn ? (
|
||||
{showUser ? (
|
||||
<div className={styles.userBadge} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<span>{userName || "会员"}</span>
|
||||
{onLogout && (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className={styles.loginLink}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", padding: 0, fontSize: "inherit" }}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-500 text-sm font-semibold text-white shadow-sm hover:bg-amber-600"
|
||||
aria-label="用户菜单"
|
||||
>
|
||||
退出
|
||||
{getAvatarLetter(displayName)}
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<span className="block px-4 py-2 text-sm text-slate-600 dark:text-slate-400 truncate max-w-[160px]">
|
||||
<Copyable text={displayName}>{displayName}</Copyable>
|
||||
</span>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden sm:inline max-w-[120px] truncate text-sm"><Copyable text={displayName}>{displayName}</Copyable></span>
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
@@ -8,13 +8,14 @@ type VipLayoutProps = {
|
||||
children: ReactNode;
|
||||
isLoggedIn: boolean;
|
||||
userName?: string | null;
|
||||
userEmail?: string | null;
|
||||
onLogout?: () => void;
|
||||
};
|
||||
|
||||
export function VipLayout({ children, isLoggedIn, userName, onLogout }: VipLayoutProps) {
|
||||
export function VipLayout({ children, isLoggedIn, userName, userEmail, onLogout }: VipLayoutProps) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<VipHeader isLoggedIn={isLoggedIn} userName={userName} onLogout={onLogout} />
|
||||
<VipHeader isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={onLogout} />
|
||||
<main className={styles.main}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Copyable } from "../Copyable";
|
||||
import styles from "../vip.module.css";
|
||||
|
||||
type WelcomeBlockProps = {
|
||||
@@ -12,8 +13,174 @@ function getStorage(key: string): string | null {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
/** 匿名 user_id 格式:user + 数字 */
|
||||
function isAnonymousUserId(val: string | null): boolean {
|
||||
return !!val && /^user\d+$/.test(val);
|
||||
}
|
||||
|
||||
export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
||||
const displayName = userName || getStorage("userName") || "会员";
|
||||
const userId = getStorage("userId");
|
||||
const needLinkEmail = isAnonymousUserId(userId);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [linked, setLinked] = useState(false);
|
||||
|
||||
const handleLinkSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (password !== passwordConfirm) {
|
||||
setError("两次密码不一致");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("密码至少 8 位");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/auth/link-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: email.trim(),
|
||||
password,
|
||||
anonymousUserId: userId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data?.ok) {
|
||||
setError(data?.error || "操作失败");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email });
|
||||
await fetch("/api/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: data.token, record: data.record }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("userId", data.record.id);
|
||||
localStorage.setItem("userName", data.record.email || data.record.id);
|
||||
}
|
||||
setLinked(true);
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setError("操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (needLinkEmail && !linked) {
|
||||
return (
|
||||
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
||||
<h1
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "clamp(1.5rem, 4vw, 2rem)",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
animationDelay: "0.1s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
欢迎加入,请补充邮箱
|
||||
</h1>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
color: "var(--muted-foreground)",
|
||||
margin: "0.25rem 0 1rem",
|
||||
animationDelay: "0.15s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
补充邮箱后可在各站点通用登录,Memos 账号密码为 12345678
|
||||
</p>
|
||||
<form onSubmit={handleLinkSubmit} className={styles.loginCard} style={{ maxWidth: "400px" }}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "1rem",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 8 位"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "1rem",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "1rem",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "处理中…" : "确认并关联"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
||||
<h1
|
||||
@@ -26,7 +193,7 @@ export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
欢迎回来,{displayName}
|
||||
欢迎回来,<Copyable text={displayName}>{displayName}</Copyable>
|
||||
</h1>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
@@ -40,23 +207,22 @@ export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
||||
>
|
||||
以下是您的会员资产与推荐动作
|
||||
</p>
|
||||
<Link
|
||||
href="https://qun.hackrobot.cn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.card}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
textDecoration: "none",
|
||||
color: "var(--accent)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
🌐 进入星球 qun.hackrobot.cn →
|
||||
</Link>
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.loginCardTitle}>星球登录信息</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>网址</span>
|
||||
<Copyable text="https://qun.hackrobot.cn">qun.hackrobot.cn</Copyable>
|
||||
<a href="https://qun.hackrobot.cn" target="_blank" rel="noopener noreferrer" className={styles.loginCardLink}>打开</a>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>账号</span>
|
||||
<Copyable text={displayName}><span className={styles.loginCardValue}>{displayName}</span></Copyable>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>密码</span>
|
||||
<Copyable text="12345678"><span className={styles.loginCardValue}>12345678</span></Copyable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L
|
||||
onClick={() => { setMode("user_id"); setError(null); }}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "user_id" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
|
||||
>
|
||||
user_id
|
||||
账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import styles from "../vip.module.css";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
import { AuthForm } from "./AuthForm";
|
||||
|
||||
type LoginModalProps = {
|
||||
open: boolean;
|
||||
@@ -14,8 +13,6 @@ type LoginModalProps = {
|
||||
};
|
||||
|
||||
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
|
||||
const [tab, setTab] = useState<"email" | "legacy">("email");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -73,41 +70,12 @@ export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4 flex gap-2 border-b border-[var(--border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("email")}
|
||||
className={`pb-2 text-sm font-medium ${tab === "email" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
|
||||
>
|
||||
邮箱登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("legacy")}
|
||||
className={`pb-2 text-sm font-medium ${tab === "legacy" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
|
||||
>
|
||||
会员账号恢复
|
||||
</button>
|
||||
</div>
|
||||
{tab === "email" ? (
|
||||
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
|
||||
使用邮箱登录,可与 digital / meetup 等站点共享账号
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
|
||||
支持 user_id 或关联 users 的邮箱验证
|
||||
</p>
|
||||
)}
|
||||
{tab === "email" ? (
|
||||
<AuthForm onSuccess={handleSuccess} autoFocus />
|
||||
) : (
|
||||
<LoginForm
|
||||
onVerify={onVerify}
|
||||
onVerifyByEmail={onVerifyByEmail}
|
||||
onSuccess={handleSuccess}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<LoginForm
|
||||
onVerify={onVerify}
|
||||
onVerifyByEmail={onVerifyByEmail}
|
||||
onSuccess={handleSuccess}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -164,6 +164,63 @@
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* 星球登录信息卡片 */
|
||||
.loginCard {
|
||||
margin-top: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.06) 0%, rgba(245, 158, 11, 0.02) 100%);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 10px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.loginCardTitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loginCardRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.loginCardRow:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.loginCardLabel {
|
||||
flex-shrink: 0;
|
||||
width: 2.5em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.loginCardValue {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--muted);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.loginCardLink {
|
||||
margin-left: auto;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.loginCardLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 大数字 */
|
||||
.bigNumber {
|
||||
font-size: 2rem;
|
||||
|
||||
Reference in New Issue
Block a user