Design System 重构继续完成

This commit is contained in:
2026-02-10 04:58:50 +08:00
parent 73d0b0d5fe
commit b8cb884e9e
56 changed files with 403 additions and 1033 deletions

View File

@@ -22,9 +22,7 @@ pnpm run start
pnpm run lint pnpm run lint
# 数据库操作 # 数据库操作
pnpm prisma generate # 生成 Prisma client 到 src/generated/prisma # 不要进行数据库操作,让用户操作数据库
pnpm prisma db push # 推送 schema 变更到数据库
pnpm prisma studio # 打开 Prisma Studio 查看数据库
``` ```
## 技术栈 ## 技术栈
@@ -102,31 +100,6 @@ src/modules/{module}/
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY``ZHIPU_MODEL_NAME` 配置。 **LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY``ZHIPU_MODEL_NAME` 配置。
### 环境变量
需要在 `.env.local` 中配置:
```env
# LLM 集成(智谱 AI 用于翻译和 IPA 生成)
ZHIPU_API_KEY=your-api-key
ZHIPU_MODEL_NAME=your-model-name
# 阿里云千问 TTS文本转语音
DASHSCORE_API_KEY=your-dashscore-api-key
# 认证
BETTER_AUTH_SECRET=your-secret
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# 数据库
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
```
## 重要配置细节
- **Prisma client 输出**: 自定义目录 `src/generated/prisma`(不是默认的 `node_modules/.prisma`
- **Standalone 输出**: 为 Docker 部署配置 - **Standalone 输出**: 为 Docker 部署配置
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化 - **React Compiler**: 在 `next.config.ts` 中启用以自动优化
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志 - **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
@@ -147,7 +120,6 @@ DATABASE_URL=postgresql://username:password@localhost:5432/database_name
## 开发注意事项 ## 开发注意事项
- 使用 pnpm而不是 npm 或 yarn - 使用 pnpm而不是 npm 或 yarn
- schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push`
- 应用使用 TypeScript 严格模式 - 确保类型安全 - 应用使用 TypeScript 严格模式 - 确保类型安全
- 所有面向用户的文本都需要国际化 - 所有面向用户的文本都需要国际化
- **优先使用 Server Components**,只在需要交互时使用 Client Components - **优先使用 Server Components**,只在需要交互时使用 Client Components

View File

@@ -3,11 +3,11 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces"; import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { IconClick, CircleToggleButton, CircleButton, PrimaryButton } from "@/components/ui/buttons"; import { IconClick, CircleToggleButton, CircleButton, PrimaryButton } from "@/design-system/base/button";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { Card } from "@/components/ui/Card"; import { Card } from "@/design-system/base/card";
interface AlphabetCardProps { interface AlphabetCardProps {
alphabet: Letter[]; alphabet: Letter[];
@@ -103,7 +103,7 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
{/* 右上角返回按钮 - outside the white card */} {/* 右上角返回按钮 - outside the white card */}
<div className="flex justify-end mb-4"> <div className="flex justify-end mb-4">
<IconClick <IconClick
size={32} size="lg"
alt="close" alt="close"
src={IMAGES.close} src={IMAGES.close}
onClick={onBack} onClick={onBack}
@@ -185,7 +185,7 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
{/* 上一个按钮 */} {/* 上一个按钮 */}
<CircleButton onClick={goToPrevious} aria-label="上一个字母"> <CircleButton onClick={goToPrevious} aria-label="上一个字母">
<ChevronLeft size={24} /> <ChevronLeft size={20} />
</CircleButton> </CircleButton>
{/* 中间区域:随机按钮 */} {/* 中间区域:随机按钮 */}
@@ -202,7 +202,7 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
{/* 下一个按钮 */} {/* 下一个按钮 */}
<CircleButton onClick={goToNext} aria-label="下一个字母"> <CircleButton onClick={goToNext} aria-label="下一个字母">
<ChevronRight size={24} /> <ChevronRight size={20} />
</CircleButton> </CircleButton>
</div> </div>
</Card> </Card>

View File

@@ -1,5 +1,5 @@
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { IconClick } from "@/components/ui/buttons"; import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces"; import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { import {
@@ -45,10 +45,10 @@ export function MemoryCard({
className="w-full flex justify-center items-center" className="w-full flex justify-center items-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()} onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
> >
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center"> <div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-lg shadow border-gray-200 border flex justify-center items-center">
<div className="w-full flex justify-end items-center"> <div className="w-full flex justify-end items-center">
<IconClick <IconClick
size={32} size="lg"
alt="close" alt="close"
src={IMAGES.close} src={IMAGES.close}
onClick={() => setChosenAlphabet(null)} onClick={() => setChosenAlphabet(null)}
@@ -64,13 +64,13 @@ export function MemoryCard({
</div> </div>
<div className="flex flex-row mt-32 items-center justify-center gap-2"> <div className="flex flex-row mt-32 items-center justify-center gap-2">
<IconClick <IconClick
size={48} size="lg"
alt="refresh" alt="refresh"
src={IMAGES.refresh} src={IMAGES.refresh}
onClick={refresh} onClick={refresh}
></IconClick> ></IconClick>
<IconClick <IconClick
size={48} size="lg"
alt="more" alt="more"
src={IMAGES.more_horiz} src={IMAGES.more_horiz}
onClick={() => setMore(!more)} onClick={() => setMore(!more)}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces"; import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { AlphabetCard } from "./AlphabetCard"; import { AlphabetCard } from "./AlphabetCard";
export default function Alphabet() { export default function Alphabet() {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/design-system/base/input";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Plus, RefreshCw } from "lucide-react"; import { Plus, RefreshCw } from "lucide-react";
import { CircleButton, LightButton } from "@/components/ui/buttons"; import { CircleButton, LightButton } from "@/design-system/base/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { actionCreatePair } from "@/modules/folder/folder-aciton"; import { actionCreatePair } from "@/modules/folder/folder-aciton";
import { TSharedItem } from "@/shared/dictionary-type"; import { TSharedItem } from "@/shared/dictionary-type";

View File

@@ -6,7 +6,7 @@ import Link from "next/link";
import { Folder as Fd } from "lucide-react"; import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type"; import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/components/ui/buttons"; import { PrimaryButton } from "@/design-system/base/button";
interface FolderSelectorProps { interface FolderSelectorProps {
folders: TSharedFolderWithTotalPairs[]; folders: TSharedFolderWithTotalPairs[];
@@ -37,7 +37,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
{t("selectFolder")} {t("selectFolder")}
</h1> </h1>
{/* 文件夹列表 */} {/* 文件夹列表 */}
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto"> <div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{folders {folders
.toSorted((a, b) => a.id - b.id) .toSorted((a, b) => a.id - b.id)
.map((folder) => ( .map((folder) => (
@@ -50,7 +50,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
> >
{/* 文件夹图标 */} {/* 文件夹图标 */}
<div className="shrink-0"> <div className="shrink-0">
<Fd className="text-gray-600" size={24} /> <Fd className="text-gray-600" size="md" />
</div> </div>
{/* 文件夹信息 */} {/* 文件夹信息 */}
<div className="flex-1"> <div className="flex-1">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { LinkButton, CircleToggleButton, LightButton } from "@/components/ui/buttons"; import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -29,7 +29,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
if (textPairs.length === 0) { if (textPairs.length === 0) {
return ( return (
<PageLayout maxWidth="md"> <PageLayout>
<p className="text-gray-700 text-center">{t("noTextPairs")}</p> <p className="text-gray-700 text-center">{t("noTextPairs")}</p>
</PageLayout> </PageLayout>
); );

View File

@@ -1,6 +1,6 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react"; import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import { SubtitleDisplay } from "./SubtitleDisplay"; import { SubtitleDisplay } from "./SubtitleDisplay";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { RangeInput } from "@/components/ui/RangeInput"; import { RangeInput } from "@/components/ui/RangeInput";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle"; import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useRef } from "react"; import React, { useRef } from "react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/design-system/base/button";
import { FileInputProps } from "../../types/controls"; import { FileInputProps } from "../../types/controls";
interface FileInputComponentProps extends FileInputProps { interface FileInputComponentProps extends FileInputProps {

View File

@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { PlayButtonProps } from "../../types/player"; import { PlayButtonProps } from "../../types/player";
export function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) { export function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { SpeedControlProps } from "../../types/player"; import { SpeedControlProps } from "../../types/player";
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils"; import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";

View File

@@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react"; import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { ControlBarProps } from "../../types/controls"; import { ControlBarProps } from "../../types/controls";
import { PlayButton } from "../atoms/PlayButton"; import { PlayButton } from "../atoms/PlayButton";
import { SpeedControl } from "../atoms/SpeedControl"; import { SpeedControl } from "../atoms/SpeedControl";

View File

@@ -4,7 +4,7 @@ import React from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { Video, FileText } from "lucide-react"; import { Video, FileText } from "lucide-react";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { FileUploadProps } from "../../types/controls"; import { FileUploadProps } from "../../types/controls";
import { useFileUpload } from "../../hooks/useFileUpload"; import { useFileUpload } from "../../hooks/useFileUpload";

View File

@@ -15,7 +15,7 @@ import { SubtitleArea } from "./components/compounds/SubtitleArea";
import { ControlBar } from "./components/compounds/ControlBar"; import { ControlBar } from "./components/compounds/ControlBar";
import { UploadZone } from "./components/compounds/UploadZone"; import { UploadZone } from "./components/compounds/UploadZone";
import { SeekBar } from "./components/atoms/SeekBar"; import { SeekBar } from "./components/atoms/SeekBar";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
export default function SrtPlayerPage() { export default function SrtPlayerPage() {
const t = useTranslations("home"); const t = useTranslations("home");
@@ -119,7 +119,7 @@ export default function SrtPlayerPage() {
</div> </div>
{/* 视频播放器区域 */} {/* 视频播放器区域 */}
<div className="aspect-video bg-black relative rounded-xl overflow-hidden"> <div className="aspect-video bg-black relative rounded-md overflow-hidden">
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && ( {(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
<div className="text-center text-white"> <div className="text-center text-white">

View File

@@ -6,7 +6,7 @@ import {
TextSpeakerArraySchema, TextSpeakerArraySchema,
TextSpeakerItemSchema, TextSpeakerItemSchema,
} from "@/lib/interfaces"; } from "@/lib/interfaces";
import { IconClick } from "@/components/ui/buttons"; import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
@@ -24,7 +24,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
handleDel(item); handleDel(item);
}; };
return ( return (
<div className="p-2 border-b border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8"> <div className="p-2 border-b border-gray-200 rounded-lg bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}> <div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto"> <div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
{item.text} {item.text}
@@ -39,7 +39,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
alt="delete" alt="delete"
onClick={onDelClick} onClick={onDelClick}
className="place-self-center" className="place-self-center"
size={42} size="lg"
></IconClick> ></IconClick>
</div> </div>
</div> </div>
@@ -81,7 +81,7 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
if (show) if (show)
return ( return (
<div <div
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-2xl" className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
style={{ fontFamily: "Times New Roman, serif" }} style={{ fontFamily: "Times New Roman, serif" }}
> >
<div className="flex flex-row justify-center gap-8 items-center"> <div className="flex flex-row justify-center gap-8 items-center">
@@ -89,14 +89,14 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
src={IMAGES.refresh} src={IMAGES.refresh}
alt="refresh" alt="refresh"
onClick={refresh} onClick={refresh}
size={48} size="lg"
className="" className=""
></IconClick> ></IconClick>
<IconClick <IconClick
src={IMAGES.delete} src={IMAGES.delete}
alt="delete" alt="delete"
onClick={handleDeleteAll} onClick={handleDeleteAll}
size={48} size="lg"
className="" className=""
></IconClick> ></IconClick>
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { IconClick } from "@/components/ui/buttons"; import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { import {
@@ -222,7 +222,7 @@ export default function TextSpeakerPage() {
<PageLayout className="items-start py-4"> <PageLayout className="items-start py-4">
{/* 文本输入区域 */} {/* 文本输入区域 */}
<div <div
className="border border-gray-200 rounded-2xl" className="border border-gray-200 rounded-lg"
style={{ fontFamily: "Times New Roman, serif" }} style={{ fontFamily: "Times New Roman, serif" }}
> >
{/* 文本输入框 */} {/* 文本输入框 */}
@@ -242,37 +242,37 @@ export default function TextSpeakerPage() {
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center"> <div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{/* 速度调节面板 */} {/* 速度调节面板 */}
{showSpeedAdjust && ( {showSpeedAdjust && (
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10"> <div className="bg-white p-6 rounded-lg border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
<IconClick <IconClick
size={45} size="lg"
onClick={letMeSetSpeed(0.5)} onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x} src={IMAGES.speed_0_5x}
alt="0.5x" alt="0.5x"
className={speed === 0.5 ? "bg-gray-200" : ""} className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick <IconClick
size={45} size="lg"
onClick={letMeSetSpeed(0.7)} onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x} src={IMAGES.speed_0_7x}
alt="0.7x" alt="0.7x"
className={speed === 0.7 ? "bg-gray-200" : ""} className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick <IconClick
size={45} size="lg"
onClick={letMeSetSpeed(1)} onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x} src={IMAGES.speed_1x}
alt="1x" alt="1x"
className={speed === 1 ? "bg-gray-200" : ""} className={speed === 1 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick <IconClick
size={45} size="lg"
onClick={letMeSetSpeed(1.2)} onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x} src={IMAGES.speed_1_2_x}
alt="1.2x" alt="1.2x"
className={speed === 1.2 ? "bg-gray-200" : ""} className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick <IconClick
size={45} size="lg"
onClick={letMeSetSpeed(1.5)} onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x} src={IMAGES.speed_1_5x}
alt="1.5x" alt="1.5x"
@@ -282,7 +282,7 @@ export default function TextSpeakerPage() {
)} )}
{/* 播放/暂停按钮 */} {/* 播放/暂停按钮 */}
<IconClick <IconClick
size={45} size="lg"
onClick={speak} onClick={speak}
src={pause ? IMAGES.play_arrow : IMAGES.pause} src={pause ? IMAGES.play_arrow : IMAGES.pause}
alt="playorpause" alt="playorpause"
@@ -290,7 +290,7 @@ export default function TextSpeakerPage() {
></IconClick> ></IconClick>
{/* 自动暂停按钮 */} {/* 自动暂停按钮 */}
<IconClick <IconClick
size={45} size="lg"
onClick={() => { onClick={() => {
setAutopause(!autopause); setAutopause(!autopause);
if (objurlRef) { if (objurlRef) {
@@ -303,7 +303,7 @@ export default function TextSpeakerPage() {
></IconClick> ></IconClick>
{/* 速度调节按钮 */} {/* 速度调节按钮 */}
<IconClick <IconClick
size={45} size="lg"
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
src={IMAGES.speed} src={IMAGES.speed}
alt="speed" alt="speed"
@@ -311,7 +311,7 @@ export default function TextSpeakerPage() {
></IconClick> ></IconClick>
{/* 保存按钮 */} {/* 保存按钮 */}
<IconClick <IconClick
size={45} size="lg"
onClick={save} onClick={save}
src={IMAGES.save} src={IMAGES.save}
alt="save" alt="save"
@@ -338,7 +338,7 @@ export default function TextSpeakerPage() {
</div> </div>
{/* 保存列表 */} {/* 保存列表 */}
{showSaveList && ( {showSaveList && (
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden"> <div className="mt-4 border border-gray-200 rounded-lg overflow-hidden">
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList> <SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</div> </div>
)} )}

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { LightButton, PrimaryButton } from "@/components/ui/buttons"; import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { IconClick } from "@/components/ui/buttons";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -101,7 +100,7 @@ export default function TranslatorPage() {
{/* Card Component - Left Side */} {/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2"> <div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */} {/* ICard1 Component */}
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2"> <div className="border border-gray-200 rounded-lg w-full h-64 p-2">
<textarea <textarea
className="resize-none h-8/12 w-full focus:outline-0" className="resize-none h-8/12 w-full focus:outline-0"
ref={taref} ref={taref}
@@ -147,7 +146,7 @@ export default function TranslatorPage() {
{/* Card Component - Right Side */} {/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2"> <div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */} {/* ICard2 Component */}
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2"> <div className="bg-gray-100 rounded-lg w-full h-64 p-2">
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div> <div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600"> <div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
{translationResult?.targetIpa || ""} {translationResult?.targetIpa || ""}
@@ -213,7 +212,8 @@ export default function TranslatorPage() {
<PrimaryButton <PrimaryButton
onClick={translate} onClick={translate}
disabled={processing} disabled={processing}
className="text-xl h-16 px-8" size="lg"
className="text-xl"
> >
{t("translate")} {t("translate")}
</PrimaryButton> </PrimaryButton>

View File

@@ -3,8 +3,8 @@
import { useState, useActionState, startTransition } from "react"; import { useState, useActionState, startTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { Input } from "@/components/ui/Input"; import { Input } from "@/design-system/base/input";
import { LightButton, LinkButton } from "@/components/ui/buttons"; import { LightButton, LinkButton } from "@/design-system/base/button";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action"; import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";

View File

@@ -7,7 +7,7 @@ import {
FolderPlus, FolderPlus,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { CircleButton, DashedButton } from "@/components/ui/buttons"; import { CircleButton, DashedButton } from "@/design-system/base/button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -36,7 +36,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
> >
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1">
<div className="shrink-0"> <div className="shrink-0">
<Fd className="text-gray-600" size={24} /> <Fd className="text-gray-600" size="md" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -167,13 +167,13 @@ export function FoldersClient({ userId }: { userId: string; }) {
// 空状态 // 空状态
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<FolderPlus size={24} className="text-gray-400" /> <FolderPlus size="md" className="text-gray-400" />
</div> </div>
<p className="text-sm">{t("noFoldersYet")}</p> <p className="text-sm">{t("noFoldersYet")}</p>
</div> </div>
) : ( ) : (
// 文件夹卡片列表 // 文件夹卡片列表
<div className="rounded-xl border border-gray-200 overflow-hidden"> <div className="rounded-md border border-gray-200 overflow-hidden">
{folders {folders
.toSorted((a, b) => a.id - b.id) .toSorted((a, b) => a.id - b.id)
.map((folder) => ( .map((folder) => (

View File

@@ -1,5 +1,5 @@
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector"; import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -67,7 +67,7 @@ export function AddTextPairModal({
} }
}} }}
> >
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4"> <div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex"> <div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center"> <h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewTextPair")} {t("addNewTextPair")}

View File

@@ -7,7 +7,7 @@ import { AddTextPairModal } from "./AddTextPairModal";
import { TextPairCard } from "./TextPairCard"; import { TextPairCard } from "./TextPairCard";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, IconButton, LinkButton } from "@/components/ui/buttons"; import { PrimaryButton, IconButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton"; import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
import { TSharedPair } from "@/shared/folder-type"; import { TSharedPair } from "@/shared/folder-type";

View File

@@ -1,6 +1,6 @@
import { Edit, Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { CircleButton } from "@/components/ui/buttons"; import { CircleButton } from "@/design-system/base/button";
import { UpdateTextPairModal } from "./UpdateTextPairModal"; import { UpdateTextPairModal } from "./UpdateTextPairModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type"; import { TSharedPair } from "@/shared/folder-type";

View File

@@ -1,5 +1,5 @@
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector"; import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -63,7 +63,7 @@ export function UpdateTextPairModal({
} }
}} }}
> >
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4"> <div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex"> <div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center"> <h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateTextPair")} {t("updateTextPair")}

View File

@@ -1,12 +1,11 @@
@import "tailwindcss"; @import "tailwindcss";
/** /**
* Design System CSS 变量 * Tailwind CSS v4 主题配置
* * 使用 @theme 指令定义主题变量
* 定义全局 CSS 变量用于主题切换和动态样式
*/ */
:root { @theme {
/* 颜色系统 */ /* 主色 - Teal */
--color-primary-50: #f0f9f8; --color-primary-50: #f0f9f8;
--color-primary-100: #e0f2f0; --color-primary-100: #e0f2f0;
--color-primary-200: #bce6e1; --color-primary-200: #bce6e1;
@@ -19,12 +18,88 @@
--color-primary-900: #122826; --color-primary-900: #122826;
--color-primary-950: #0a1413; --color-primary-950: #0a1413;
/* 语义色 */ /* 中性色 */
--color-success-500: #22c55e; --color-gray-50: #f9fafb;
--color-warning-500: #f59e0b; --color-gray-100: #f3f4f6;
--color-error-500: #ef4444; --color-gray-200: #e5e7eb;
--color-info-500: #3b82f6; --color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* 语义色 - Success */
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
--color-success-950: #052e16;
/* 语义色 - Warning */
--color-warning-50: #fffbeb;
--color-warning-100: #fef3c7;
--color-warning-200: #fde68a;
--color-warning-300: #fcd34d;
--color-warning-400: #fbbf24;
--color-warning-500: #f59e0b;
--color-warning-600: #d97706;
--color-warning-700: #b45309;
--color-warning-800: #92400e;
--color-warning-900: #78350f;
--color-warning-950: #451a03;
/* 语义色 - Error */
--color-error-50: #fef2f2;
--color-error-100: #fee2e2;
--color-error-200: #fecaca;
--color-error-300: #fca5a5;
--color-error-400: #f87171;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-error-700: #b91c1c;
--color-error-800: #991b1b;
--color-error-900: #7f1d1d;
--color-error-950: #450a0a;
/* 语义色 - Info */
--color-info-50: #eff6ff;
--color-info-100: #dbeafe;
--color-info-200: #bfdbfe;
--color-info-300: #93c5fd;
--color-info-400: #60a5fa;
--color-info-500: #3b82f6;
--color-info-600: #2563eb;
--color-info-700: #1d4ed8;
--color-info-800: #1e40af;
--color-info-900: #1e3a8a;
--color-info-950: #172554;
/* 圆角 - 更小的圆角 */
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.625rem;
--radius-2xl: 0.75rem;
--radius-3xl: 1rem;
--radius-full: 9999px;
}
/**
* Design System CSS 变量
*
* 定义全局 CSS 变量用于主题切换和动态样式
*/
:root {
/* 基础颜色 */ /* 基础颜色 */
--background: #ffffff; --background: #ffffff;
--foreground: #111827; --foreground: #111827;
@@ -41,13 +116,14 @@
--border-secondary: #e5e7eb; --border-secondary: #e5e7eb;
--border-focus: #35786f; --border-focus: #35786f;
/* 圆角 */ /* 圆角 - 更小的圆角 */
--radius-sm: 0.125rem; --radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.375rem;
--radius-lg: 0.5rem; --radius-lg: 0.5rem;
--radius-xl: 0.75rem; --radius-xl: 0.625rem;
--radius-2xl: 1rem; --radius-2xl: 0.75rem;
--radius-3xl: 1.5rem; --radius-3xl: 1rem;
--radius-full: 9999px; --radius-full: 9999px;
/* 阴影 */ /* 阴影 */

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/design-system/base/button";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -1,7 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LinkButton } from "@/components/ui/buttons"; import { LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository"; import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { GhostButton } from "../ui/buttons"; import { GhostLightButton } from "@/design-system/base/button";
import { useState } from "react"; import { useState } from "react";
import { Languages } from "lucide-react"; import { Languages } from "lucide-react";
@@ -15,59 +15,59 @@ export function LanguageSettings() {
}; };
return ( return (
<> <>
<Languages onClick={handleLanguageClick} size={28} className="navbar-btn" /> <Languages onClick={handleLanguageClick} size={28} className="text-white hover:text-white/80" />
<div className="relative"> <div className="relative">
{showLanguageMenu && ( {showLanguageMenu && (
<div> <div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2"> <div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("en-US")} onClick={() => setLocale("en-US")}
> >
English English
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("zh-CN")} onClick={() => setLocale("zh-CN")}
> >
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("ja-JP")} onClick={() => setLocale("ja-JP")}
> >
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("ko-KR")} onClick={() => setLocale("ko-KR")}
> >
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("de-DE")} onClick={() => setLocale("de-DE")}
> >
Deutsch Deutsch
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("fr-FR")} onClick={() => setLocale("fr-FR")}
> >
Français Français
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("it-IT")} onClick={() => setLocale("it-IT")}
> >
Italiano Italiano
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="w-full bg-[#35786f]" className="w-full bg-[#35786f]"
onClick={() => setLocale("ug-CN")} onClick={() => setLocale("ug-CN")}
> >
ئۇيغۇرچە ئۇيغۇرچە
</GhostButton> </GhostLightButton>
</div> </div>
</div> </div>
)} )}

View File

@@ -5,7 +5,7 @@ import { LanguageSettings } from "./LanguageSettings";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { GhostButton } from "../ui/buttons"; import { GhostLightButton } from "@/design-system/base/button";
export async function Navbar() { export async function Navbar() {
const t = await getTranslations("navbar"); const t = await getTranslations("navbar");
@@ -15,16 +15,17 @@ export async function Navbar() {
return ( return (
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white"> <div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
<GhostButton href="/" className="text-lg md:text-xl border-b hidden! md:block!"> <GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
{t("title")} {t("title")}
</GhostButton> </GhostLightButton>
<GhostButton className="block! md:hidden!" href={"/"}> <GhostLightButton className="block! md:hidden!" size="md" href={"/"}>
<Home size={20} /> <Home size={20} />
</GhostButton> </GhostLightButton>
<div className="flex text-base md:text-xl gap-0.5 justify-center items-center flex-wrap"> <div className="flex gap-0.5 justify-center items-center flex-wrap">
<LanguageSettings /> <LanguageSettings />
<GhostButton <GhostLightButton
className="md:hidden! block! navbar-btn p-2" className="md:hidden! block!"
size="md"
href="https://github.com/GoddoNebianU/learn-languages" href="https://github.com/GoddoNebianU/learn-languages"
> >
<Image <Image
@@ -33,33 +34,34 @@ export async function Navbar() {
width={20} width={20}
height={20} height={20}
/> />
</GhostButton> </GhostLightButton>
<GhostButton href="/folders" className="md:block! hidden! navbar-btn"> <GhostLightButton href="/folders" className="md:block! hidden!" size="md">
{t("folders")} {t("folders")}
</GhostButton> </GhostLightButton>
<GhostButton href="/folders" className="md:hidden! block! navbar-btn p-2"> <GhostLightButton href="/folders" className="md:hidden! block!" size="md">
<Folder size={20} /> <Folder size={20} />
</GhostButton> </GhostLightButton>
<GhostButton <GhostLightButton
className="hidden! md:block! navbar-btn" className="hidden! md:block!"
size="md"
href="https://github.com/GoddoNebianU/learn-languages" href="https://github.com/GoddoNebianU/learn-languages"
> >
{t("sourceCode")} {t("sourceCode")}
</GhostButton> </GhostLightButton>
{ {
(() => { (() => {
return session && return session &&
<> <>
<GhostButton href="/profile" className="hidden! md:block! text-sm md:text-base navbar-btn px-2 py-1">{t("profile")}</GhostButton> <GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
<GhostButton href="/profile" className="md:hidden! block! navbar-btn p-2"> <GhostLightButton href="/profile" className="md:hidden! block!" size="md">
<User size={20} /> <User size={20} />
</GhostButton> </GhostLightButton>
</> </>
|| <> || <>
<GhostButton href="/auth" className="hidden! md:block! text-sm md:text-base navbar-btn px-2 py-1">{t("sign_in")}</GhostButton> <GhostLightButton href="/auth" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
<GhostButton href="/auth" className="md:hidden! block! navbar-btn p-2"> <GhostLightButton href="/auth" className="md:hidden! block!" size="md">
<User size={20} /> <User size={20} />
</GhostButton> </GhostLightButton>
</>; </>;
})() })()
} }

View File

@@ -1,180 +0,0 @@
"use client";
import React from "react";
import Link from "next/link";
import Image from "next/image";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon" | "circle" | "dashed" | "link";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps {
// Content
children?: React.ReactNode;
// Behavior
onClick?: () => void;
disabled?: boolean;
type?: "button" | "submit" | "reset";
// Styling
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
selected?: boolean;
style?: React.CSSProperties;
// Icons
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
iconSrc?: string; // For Next.js Image icons
iconAlt?: string;
// Navigation
href?: string;
}
export function Button({
variant = "secondary",
size = "md",
selected = false,
href,
iconSrc,
iconAlt,
leftIcon,
rightIcon,
children,
className = "",
style,
type = "button",
disabled = false,
...props
}: ButtonProps) {
// Base classes
const baseClasses = "inline-flex items-center justify-center gap-2 rounded font-bold shadow hover:cursor-pointer transition-colors";
// Variant-specific classes
const variantStyles: Record<ButtonVariant, string> = {
primary: `
text-white
hover:opacity-90
`,
secondary: `
text-black
hover:bg-gray-100
`,
ghost: `
hover:bg-black/30
p-2
`,
icon: `
p-2 bg-gray-200 rounded-full
hover:bg-gray-300
`,
circle: `
p-2 rounded-full
hover:bg-gray-200
`,
dashed: `
border-2 border-dashed border-gray-300
text-gray-500
hover:border-gray-400
hover:text-gray-600
bg-transparent
shadow-none
`,
link: `
text-[#35786f]
hover:underline
p-0
shadow-none
`
};
// Size-specific classes
const sizeStyles: Record<ButtonSize, string> = {
sm: "px-3 py-1 text-sm",
md: "px-4 py-2",
lg: "px-6 py-3 text-lg"
};
const variantClass = variantStyles[variant];
const sizeClass = sizeStyles[size];
// Selected state for secondary variant
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
// Background color for primary variant
const backgroundColor = variant === "primary" ? '#35786f' : undefined;
// Combine all classes
const combinedClasses = `
${baseClasses}
${variantClass}
${sizeClass}
${selectedClass}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`.trim().replace(/\s+/g, " ");
// Icon rendering helper for SVG icons
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
if (!icon) return null;
return (
<span className={`flex items-center ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
{icon}
</span>
);
};
// Image icon rendering for Next.js Image
const renderImageIcon = () => {
if (!iconSrc) return null;
const sizeMap = { sm: 16, md: 20, lg: 24 };
const imgSize = sizeMap[size] || 20;
return (
<Image
src={iconSrc}
width={imgSize}
height={imgSize}
alt={iconAlt || "icon"}
/>
);
};
// Content assembly
const content = (
<>
{renderImageIcon()}
{renderSvgIcon(leftIcon, "left")}
{children}
{renderSvgIcon(rightIcon, "right")}
</>
);
// If href is provided, render as Link
if (href) {
return (
<Link
href={href}
className={combinedClasses}
style={{ ...style, backgroundColor }}
>
{content}
</Link>
);
}
// Otherwise render as button
return (
<button
type={type}
disabled={disabled}
className={combinedClasses}
style={{ ...style, backgroundColor }}
{...props}
>
{content}
</button>
);
}

View File

@@ -1,73 +0,0 @@
/**
* Card - 可复用的卡片组件
*
* 提供应用统一的标准白色卡片样式:
* - 白色背景
* - 圆角 (rounded-2xl)
* - 阴影 (shadow-xl)
* - 可配置内边距
* - 多种样式变体
*
* @example
* ```tsx
* // 默认卡片
* <Card>
* <p>卡片内容</p>
* </Card>
*
* // 带边框的卡片
* <Card variant="bordered" padding="lg">
* <p>带边框的内容</p>
* </Card>
*
* // 无内边距卡片
* <Card padding="none">
* <img src="image.jpg" alt="完全填充的图片" />
* </Card>
* ```
*/
export type CardVariant = "default" | "bordered" | "elevated";
export type CardPadding = "none" | "sm" | "md" | "lg" | "xl";
export interface CardProps {
children: React.ReactNode;
/** 额外的 CSS 类名,用于自定义样式 */
className?: string;
/** 卡片样式变体 */
variant?: CardVariant;
/** 内边距大小 */
padding?: CardPadding;
}
// 变体样式映射
const variantClasses: Record<CardVariant, string> = {
default: "bg-white shadow-xl",
bordered: "bg-white border-2 border-gray-200",
elevated: "bg-white shadow-2xl",
};
// 内边距映射
const paddingClasses: Record<CardPadding, string> = {
none: "",
sm: "p-4",
md: "p-6",
lg: "p-8",
xl: "p-8 md:p-12",
};
export function Card({
children,
className = "",
variant = "default",
padding = "md",
}: CardProps) {
const baseClasses = "rounded-2xl";
const variantClass = variantClasses[variant];
const paddingClass = paddingClasses[padding];
return (
<div className={`${baseClasses} ${variantClass} ${paddingClass} ${className}`}>
{children}
</div>
);
}

View File

@@ -1,30 +1,21 @@
/** /**
* CardList - 可滚动的卡片列表容器 * CardList - 可滚动的卡片列表容器
* *
* 用于显示可滚动的列表内容,如文件夹列表、文本对列表等 * 使用 Design System 重写的卡片列表组件
* - 最大高度 96 (24rem)
* - 垂直滚动
* - 圆角边框
*
* @example
* ```tsx
* <CardList>
* {items.map(item => (
* <div key={item.id}>{item.name}</div>
* ))}
* </CardList>
* ```
*/ */
import { VStack } from "@/design-system/layout/stack";
interface CardListProps { interface CardListProps {
children: React.ReactNode; children: React.ReactNode;
/** 额外的 CSS 类名 */
className?: string; className?: string;
} }
export function CardList({ children, className = "" }: CardListProps) { export function CardList({ children, className = "" }: CardListProps) {
return ( return (
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}> <div className={`max-h-96 overflow-y-auto rounded-lg border-2 border-gray-200 ${className}`}>
{children} <VStack gap={0}>
{children}
</VStack>
</div> </div>
); );
} }

View File

@@ -1,14 +1,22 @@
/**
* Container - 容器组件
*
* 使用 Design System 重写的容器组件
*/
import { Container as DSContainer } from "@/design-system/layout/container";
import { Card } from "@/design-system/base/card";
interface ContainerProps { interface ContainerProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
} }
export function Container({ children, className }: ContainerProps) { export function Container({ children, className = "" }: ContainerProps) {
return ( return (
<div <DSContainer size="2xl" className={`mx-auto ${className}`}>
className={`w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl ${className}`} <Card variant="bordered" padding="md">
> {children}
{children} </Card>
</div> </DSContainer>
); );
} }

View File

@@ -1,54 +0,0 @@
export type InputVariant = "default" | "search" | "bordered" | "filled";
interface Props {
ref?: React.Ref<HTMLInputElement>;
placeholder?: string;
type?: string;
className?: string;
name?: string;
defaultValue?: string;
value?: string;
variant?: InputVariant;
required?: boolean;
disabled?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export function Input({
ref,
placeholder = "",
type = "text",
className = "",
name = "",
defaultValue = "",
value,
variant = "default",
required = false,
disabled = false,
onChange,
}: Props) {
// Variant-specific classes
const variantStyles: Record<InputVariant, string> = {
default: "block focus:outline-none border-b-2 border-gray-600",
search: "flex-1 min-w-0 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded",
bordered: "w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]",
filled: "w-full px-3 py-2 bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
};
const variantClass = variantStyles[variant];
return (
<input
ref={ref}
placeholder={placeholder}
type={type}
className={`${variantClass} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
name={name}
defaultValue={defaultValue}
value={value}
required={required}
disabled={disabled}
onChange={onChange}
/>
);
}

View File

@@ -1,7 +1,13 @@
/**
* LocaleSelector - 语言选择器组件
*
* 使用 Design System 重写的语言选择器组件
*/
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@/components/ui/Input"; import { Input } from "@/design-system/base/input";
import { Select, Option } from "@/components/ui/Select"; import { Select } from "@/design-system/base/select";
import { VStack } from "@/design-system/layout/stack";
const COMMON_LANGUAGES = [ const COMMON_LANGUAGES = [
{ label: "chinese", value: "chinese" }, { label: "chinese", value: "chinese" },
@@ -38,7 +44,8 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
}; };
// 当选择常见语言或"其他"时 // 当选择常见语言或"其他"时
const handleSelectChange = (selectedValue: string) => { const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValue = e.target.value;
if (selectedValue === "other") { if (selectedValue === "other") {
setCustomInput(""); setCustomInput("");
onChange("other"); onChange("other");
@@ -48,15 +55,15 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
}; };
return ( return (
<div> <VStack gap={2}>
<Select <Select
value={isCommonLanguage ? value : "other"} value={isCommonLanguage ? value : "other"}
onChange={handleSelectChange} onChange={handleSelectChange}
> >
{COMMON_LANGUAGES.map((lang) => ( {COMMON_LANGUAGES.map((lang) => (
<Option key={lang.value} value={lang.value}> <option key={lang.value} value={lang.value}>
{t(`translator.${lang.label}`)} {t(`translator.${lang.label}`)}
</Option> </option>
))} ))}
</Select> </Select>
{showCustomInput && ( {showCustomInput && (
@@ -66,9 +73,8 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
onChange={(e) => handleCustomInputChange(e.target.value)} onChange={(e) => handleCustomInputChange(e.target.value)}
placeholder={t("folder_id.enterLanguageName")} placeholder={t("folder_id.enterLanguageName")}
variant="bordered" variant="bordered"
className="mt-2"
/> />
)} )}
</div> </VStack>
); );
} }

View File

@@ -1,29 +1,25 @@
/** /**
* PageHeader - 页面标题组件 * PageHeader - 页面标题组件
* *
* 用于 PageLayout 内的页面标题,支持主标题和可选的副标题 * 使用 Design System 重写的页面标题组件
*
* @example
* ```tsx
* <PageHeader title="我的文件夹" subtitle="管理和组织你的学习内容" />
* ```
*/ */
import { VStack } from "@/design-system/layout/stack";
interface PageHeaderProps { interface PageHeaderProps {
/** 页面主标题 */
title: string; title: string;
/** 可选的副标题/描述 */
subtitle?: string; subtitle?: string;
className?: string;
} }
export function PageHeader({ title, subtitle }: PageHeaderProps) { export function PageHeader({ title, subtitle, className = "" }: PageHeaderProps) {
return ( return (
<div className="mb-6"> <VStack gap={2} className={`mb-6 ${className}`}>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2"> <h1 className="text-2xl md:text-3xl font-bold text-gray-800">
{title} {title}
</h1> </h1>
{subtitle && ( {subtitle && (
<p className="text-sm text-gray-500">{subtitle}</p> <p className="text-sm text-gray-500">{subtitle}</p>
)} )}
</div> </VStack>
); );
} }

View File

@@ -1,61 +1,21 @@
/** /**
* PageLayout - 统一的页面布局组件 * PageLayout - 页面布局组件
* *
* 提供应用统一的标准页面布局 * 使用 Design System 重写的页面布局组件
* - 绿色背景 (#35786f)
* - 最小高度 min-h-[calc(100vh-64px)]
* - 支持多种布局变体
*
* @example
* ```tsx
* // 默认:居中白色卡片布局
* <PageLayout>
* <PageHeader title="标题" subtitle="副标题" />
* <div>页面内容</div>
* </PageLayout>
*
* // 全宽布局(无白色卡片)
* <PageLayout variant="full-width" maxWidth="3xl">
* <div>页面内容</div>
* </PageLayout>
*
* // 全屏布局(用于 translator 等)
* <PageLayout variant="fullscreen">
* <div>全屏内容</div>
* </PageLayout>
* ```
*/ */
import { Card } from "./Card"; import { Card } from "@/design-system/base/card";
import { Container } from "@/design-system/layout/container";
type PageLayoutVariant = "centered-card" | "full-width" | "fullscreen"; type PageLayoutVariant = "centered-card" | "full-width" | "fullscreen";
type MaxWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full";
type AlignItems = "center" | "start" | "end";
interface PageLayoutProps { interface PageLayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** 额外的 CSS 类名,用于自定义布局行为 */
className?: string; className?: string;
/** 布局变体 */
variant?: PageLayoutVariant; variant?: PageLayoutVariant;
/** 最大宽度(仅对 full-width 变体有效) */ align?: "center" | "start" | "end";
maxWidth?: MaxWidth;
/** 内容垂直对齐方式(仅对 centered-card 变体有效) */
align?: AlignItems;
} }
// 最大宽度映射 const alignClasses = {
const maxWidthClasses: Record<MaxWidth, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
full: "max-w-full",
};
// 对齐方式映射
const alignClasses: Record<AlignItems, string> = {
center: "items-center", center: "items-center",
start: "items-start", start: "items-start",
end: "items-end", end: "items-end",
@@ -65,13 +25,12 @@ export function PageLayout({
children, children,
className = "", className = "",
variant = "centered-card", variant = "centered-card",
maxWidth = "2xl",
align = "center", align = "center",
}: PageLayoutProps) { }: PageLayoutProps) {
// 默认变体:居中白色卡片布局 // 居中卡片布局
if (variant === "centered-card") { if (variant === "centered-card") {
return ( return (
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex ${alignClasses[align]} justify-center px-4 py-8 ${className}`}> <div className={`min-h-[calc(100vh-64px)] bg-primary-500 flex ${alignClasses[align]} justify-center px-4 py-8 ${className}`}>
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<Card padding="lg" className="p-6 md:p-8"> <Card padding="lg" className="p-6 md:p-8">
{children} {children}
@@ -81,21 +40,21 @@ export function PageLayout({
); );
} }
// 全宽布局:绿色背景,最大宽度容器,无白色卡片 // 全宽布局
if (variant === "full-width") { if (variant === "full-width") {
return ( return (
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] px-4 py-8 ${className}`}> <div className={`min-h-[calc(100vh-64px)] bg-primary-500 px-4 py-8 ${className}`}>
<div className={`w-full ${maxWidthClasses[maxWidth]} mx-auto`}> <Container size="2xl">
{children} {children}
</div> </Container>
</div> </div>
); );
} }
// 全屏布局:仅绿色背景,无其他限制 // 全屏布局
if (variant === "fullscreen") { if (variant === "fullscreen") {
return ( return (
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] ${className}`}> <div className={`min-h-[calc(100vh-64px)] bg-primary-500 ${className}`}>
{children} {children}
</div> </div>
); );

View File

@@ -34,7 +34,9 @@ export function RangeInput({
value={value} value={value}
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`} className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500 ${
disabled ? "opacity-50 cursor-not-allowed" : ""
} ${className}`}
style={{ style={{
background: `linear-gradient(to right, #374151 0%, #374151 ${progressPercentage}%, #e5e7eb ${progressPercentage}%, #e5e7eb 100%)` background: `linear-gradient(to right, #374151 0%, #374151 ${progressPercentage}%, #e5e7eb ${progressPercentage}%, #e5e7eb 100%)`
}} }}

View File

@@ -1,56 +0,0 @@
export type SelectSize = "sm" | "md" | "lg";
interface Props {
value?: string;
onChange?: (value: string) => void;
children: React.ReactNode;
className?: string;
size?: SelectSize;
disabled?: boolean;
required?: boolean;
}
export function Select({
value,
onChange,
children,
className = "",
size = "md",
disabled = false,
required = false,
}: Props) {
// Size-specific classes
const sizeStyles: Record<SelectSize, string> = {
sm: "px-2 py-1 text-sm",
md: "px-3 py-2 text-base",
lg: "px-4 py-3 text-lg"
};
const sizeClass = sizeStyles[size];
return (
<select
value={value}
onChange={(e) => onChange?.(e.target.value)}
className={`w-full border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] ${sizeClass} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
disabled={disabled}
required={required}
>
{children}
</select>
);
}
interface OptionProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
}
export function Option({ value, children, disabled = false }: OptionProps) {
return (
<option value={value} disabled={disabled}>
{children}
</option>
);
}

View File

@@ -1,50 +0,0 @@
export type TextareaVariant = "default" | "bordered" | "filled";
interface Props {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
variant?: TextareaVariant;
disabled?: boolean;
required?: boolean;
rows?: number;
name?: string;
}
export function Textarea({
value,
defaultValue,
onChange,
placeholder = "",
className = "",
variant = "default",
disabled = false,
required = false,
rows = 3,
name = "",
}: Props) {
// Variant-specific classes
const variantStyles: Record<TextareaVariant, string> = {
default: "block focus:outline-none border-b-2 border-gray-600 resize-none",
bordered: "w-full border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] resize-none",
filled: "w-full bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] resize-none"
};
const variantClass = variantStyles[variant];
return (
<textarea
value={value}
defaultValue={defaultValue}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
className={`${variantClass} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
disabled={disabled}
required={required}
rows={rows}
name={name}
/>
);
}

View File

@@ -1,89 +0,0 @@
// 统一的按钮组件导出
// 基于 Button 组件的便捷包装器,提供语义化的按钮类型
import { Button } from "../Button";
// ========== 基础按钮 ==========
// PrimaryButton: 主要操作按钮(主题色)
export const PrimaryButton = (props: any) => <Button variant="primary" {...props} />;
// SecondaryButton: 次要按钮,支持 selected 状态
export const SecondaryButton = (props: any) => <Button variant="secondary" {...props} />;
// LightButton: 次要按钮的别名(向后兼容)
export const LightButton = SecondaryButton;
// ========== 图标按钮 ==========
// IconButton: SVG 图标按钮(方形背景)
export const IconButton = (props: any) => {
const { icon, ...rest } = props;
return <Button variant="icon" leftIcon={icon} {...rest} />;
};
// IconClick: 图片图标按钮(支持 Next.js Image
export const IconClick = (props: any) => {
const { src, alt, size, disableOnHoverBgChange, className, ...rest } = props;
let buttonSize: "sm" | "md" | "lg" = "md";
if (typeof size === "number") {
if (size <= 20) buttonSize = "sm";
else if (size >= 32) buttonSize = "lg";
} else if (typeof size === "string") {
buttonSize = (size === "sm" || size === "md" || size === "lg") ? size : "md";
}
const hoverClass = disableOnHoverBgChange ? "hover:bg-black/30 hover:cursor-pointer border-0 bg-transparent shadow-none" : "";
return (
<Button
variant="icon"
iconSrc={src}
iconAlt={alt}
size={buttonSize}
className={`${hoverClass} ${className || ""}`}
{...rest}
/>
);
};
// CircleButton: 圆形图标按钮
export const CircleButton = (props: any) => {
const { icon, className, ...rest } = props;
return <Button variant="circle" leftIcon={icon} className={className} {...rest} />;
};
// CircleToggleButton: 带选中状态的圆形切换按钮
export const CircleToggleButton = (props: any) => {
const { selected, className, children, ...rest } = props;
const selectedClass = selected
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600 hover:bg-gray-300";
return (
<Button
variant="circle"
className={`rounded-full px-3 py-1 text-sm transition-colors ${selectedClass} ${className || ""}`}
{...rest}
>
{children}
</Button>
);
};
// ========== 特殊样式按钮 ==========
// GhostButton: 透明导航按钮
export const GhostButton = (props: any) => {
const { className, children, ...rest } = props;
return (
<Button variant="ghost" className={className} {...rest}>
{children}
</Button>
);
};
// LinkButton: 链接样式按钮
export const LinkButton = (props: any) => <Button variant="link" {...props} />;
// DashedButton: 虚线边框按钮
export const DashedButton = (props: any) => <Button variant="dashed" {...props} />;

View File

@@ -1,38 +1,37 @@
// 统一的 UI 组件导出 // 统一的 UI 组件导出
// 可以从 '@/components/ui' 导入所有组件 // 可以从 '@/components/ui' 导入所有组件
// 表单组件 // Design System 组件(向后兼容)
export { Input } from './Input'; export { Input, type InputVariant, type InputProps } from '@/design-system/base/input';
export { Select, Option } from './Select'; export { Select, type SelectVariant, type SelectSize, type SelectProps } from '@/design-system/base/select';
export { Textarea } from './Textarea'; export { Textarea, type TextareaVariant, type TextareaProps } from '@/design-system/base/textarea';
export { RangeInput } from './RangeInput'; export { Card, type CardVariant, type CardPadding, type CardProps } from '@/design-system/base/card';
export type { InputVariant } from './Input';
export type { SelectSize } from './Select';
export type { TextareaVariant } from './Textarea';
// 按钮组件
export { Button } from './Button';
export { export {
Button,
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
LightButton, LightButton,
SuccessButton,
WarningButton,
ErrorButton,
GhostButton,
GhostLightButton,
OutlineButton,
LinkButton,
IconButton, IconButton,
IconClick, IconClick,
CircleButton, CircleButton,
CircleToggleButton, CircleToggleButton,
GhostButton,
LinkButton,
DashedButton, DashedButton,
} from './buttons'; type ButtonVariant,
export type { ButtonVariant, ButtonSize, ButtonProps } from './Button'; type ButtonSize,
type ButtonProps
} from '@/design-system/base/button';
// 布局组件 // 业务特定组件
export { RangeInput } from './RangeInput';
export { Container } from './Container'; export { Container } from './Container';
export { PageLayout } from './PageLayout'; export { PageLayout } from './PageLayout';
export { PageHeader } from './PageHeader'; export { PageHeader } from './PageHeader';
export { CardList } from './CardList'; export { CardList } from './CardList';
export { Card } from './Card';
export type { CardProps, CardVariant, CardPadding } from './Card';
// 复合组件
export { LocaleSelector } from './LocaleSelector'; export { LocaleSelector } from './LocaleSelector';

View File

@@ -41,7 +41,7 @@ import { cn } from "@/design-system/lib/utils";
*/ */
const buttonVariants = cva( const buttonVariants = cva(
// 基础样式 // 基础样式
"inline-flex items-center justify-center gap-2 rounded-xl font-semibold shadow transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center gap-2 rounded-md font-semibold shadow transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
@@ -51,6 +51,7 @@ const buttonVariants = cva(
warning: "bg-warning-500 text-white hover:bg-warning-600 shadow-md", warning: "bg-warning-500 text-white hover:bg-warning-600 shadow-md",
error: "bg-error-500 text-white hover:bg-error-600 shadow-md", error: "bg-error-500 text-white hover:bg-error-600 shadow-md",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 shadow-none", ghost: "bg-transparent text-gray-700 hover:bg-gray-100 shadow-none",
"ghost-light": "bg-transparent text-white hover:bg-white/10 shadow-none",
outline: "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 shadow-none", outline: "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 shadow-none",
link: "text-primary-500 hover:text-primary-600 hover:underline shadow-none px-0", link: "text-primary-500 hover:text-primary-600 hover:underline shadow-none px-0",
}, },
@@ -138,15 +139,18 @@ export function Button({
type = "button", type = "button",
...props ...props
}: ButtonProps) { }: ButtonProps) {
// 确保 size 有默认值
const actualSize = size ?? "md";
// 计算样式 // 计算样式
const computedClass = cn( const computedClass = cn(
buttonVariants({ variant, size, fullWidth }), buttonVariants({ variant, size: actualSize, fullWidth }),
selected && variant === "secondary" && "bg-gray-200", selected && variant === "secondary" && "bg-gray-200",
className className
); );
// 图标尺寸映射 // 图标尺寸映射
const iconSize = { sm: 14, md: 16, lg: 20 }[size]; const iconSize = { sm: 14, md: 16, lg: 20 }[actualSize];
// 渲染 SVG 图标 // 渲染 SVG 图标
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => { const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
@@ -248,6 +252,9 @@ export const SecondaryButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="secondary" {...props} /> <Button variant="secondary" {...props} />
); );
// LightButton: 次要按钮的别名(向后兼容)
export const LightButton = SecondaryButton;
export const SuccessButton = (props: Omit<ButtonProps, "variant">) => ( export const SuccessButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="success" {...props} /> <Button variant="success" {...props} />
); );
@@ -264,6 +271,11 @@ export const GhostButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="ghost" {...props} /> <Button variant="ghost" {...props} />
); );
// GhostLightButton: 透明按钮(白色文字,用于深色背景)
export const GhostLightButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="ghost-light" {...props} />
);
export const OutlineButton = (props: Omit<ButtonProps, "variant">) => ( export const OutlineButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="outline" {...props} /> <Button variant="outline" {...props} />
); );
@@ -271,3 +283,69 @@ export const OutlineButton = (props: Omit<ButtonProps, "variant">) => (
export const LinkButton = (props: Omit<ButtonProps, "variant">) => ( export const LinkButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="link" {...props} /> <Button variant="link" {...props} />
); );
// ========== 其他便捷组件 ==========
// IconButton: SVG 图标按钮(使用 ghost 变体)
export const IconButton = (props: Omit<ButtonProps, "variant"> & { icon?: React.ReactNode }) => {
const { icon, ...rest } = props;
return <Button variant="ghost" leftIcon={icon} {...rest} />;
};
// IconClick: 图片图标按钮(支持 Next.js Image
export const IconClick = (props: Omit<ButtonProps, "variant"> & {
src?: string;
alt?: string;
size?: number | "sm" | "md" | "lg";
disableOnHoverBgChange?: boolean;
}) => {
const { src, alt, size, disableOnHoverBgChange, className, ...rest } = props;
let buttonSize: "sm" | "md" | "lg" = "md";
if (typeof size === "number") {
if (size <= 20) buttonSize = "sm";
else if (size >= 32) buttonSize = "lg";
} else if (typeof size === "string") {
buttonSize = (size === "sm" || size === "md" || size === "lg") ? size : "md";
}
const hoverClass = disableOnHoverBgChange ? "hover:bg-black/30" : "";
return (
<Button
variant="ghost"
iconSrc={src}
iconAlt={alt}
size={buttonSize}
className={`${hoverClass} ${className || ""}`}
{...rest}
/>
);
};
// CircleButton: 圆形图标按钮
export const CircleButton = (props: Omit<ButtonProps, "variant"> & { icon?: React.ReactNode }) => {
const { icon, className, ...rest } = props;
return <Button variant="ghost" leftIcon={icon} className={`rounded-full ${className || ""}`} {...rest} />;
};
// CircleToggleButton: 带选中状态的圆形切换按钮
export const CircleToggleButton = (props: Omit<ButtonProps, "variant"> & { selected?: boolean }) => {
const { selected, className, children, ...rest } = props;
const selectedClass = selected
? "bg-primary-500 text-white"
: "bg-gray-200 text-gray-600 hover:bg-gray-300";
return (
<Button
variant="secondary"
className={`rounded-full px-3 py-1 text-sm transition-colors ${selectedClass} ${className || ""}`}
{...rest}
>
{children}
</Button>
);
};
// DashedButton: 虚线边框按钮(使用 outline 变体近似)
export const DashedButton = (props: Omit<ButtonProps, "variant">) => (
<Button variant="outline" className="border-dashed" {...props} />
);

View File

@@ -36,7 +36,7 @@ import { cn } from "@/design-system/lib/utils";
*/ */
const cardVariants = cva( const cardVariants = cva(
// 基础样式 // 基础样式
"rounded-2xl bg-white transition-all duration-250", "rounded-lg bg-white transition-all duration-250",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -34,11 +34,11 @@ import { cn } from "@/design-system/lib/utils";
*/ */
const inputVariants = cva( const inputVariants = cva(
// 基础样式 // 基础样式
"flex w-full rounded-xl border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex w-full rounded-md border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: "border-b-2 border-gray-300 bg-transparent rounded-t-xl", default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
bordered: "border-gray-300 bg-white", bordered: "border-gray-300 bg-white",
filled: "border-transparent bg-gray-100", filled: "border-transparent bg-gray-100",
search: "border-gray-200 bg-white pl-10 rounded-full", search: "border-gray-200 bg-white pl-10 rounded-full",

View File

@@ -186,12 +186,13 @@ export function RadioGroup({
// 为每个 Radio 注入 name 和 onChange // 为每个 Radio 注入 name 和 onChange
const enhancedChildren = React.Children.map(children, (child) => { const enhancedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) { if (React.isValidElement(child)) {
return React.cloneElement(child, { const childProps = child.props as { value?: string; onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void };
return React.cloneElement(child as React.ReactElement<any>, {
name, name,
checked: value !== undefined ? child.props.value === value : undefined, checked: value !== undefined ? childProps.value === value : undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => { onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value); onChange?.(e.target.value);
child.props.onChange?.(e); childProps.onChange?.(e);
}, },
}); });
} }

View File

@@ -24,11 +24,11 @@ import { cn } from "@/design-system/lib/utils";
*/ */
const selectVariants = cva( const selectVariants = cva(
// 基础样式 // 基础样式
"flex w-full appearance-none items-center justify-between rounded-xl border px-3 py-2 pr-8 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex w-full appearance-none items-center justify-between rounded-md border px-3 py-2 pr-8 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: "border-b-2 border-gray-300 bg-transparent rounded-t-xl", default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
bordered: "border-gray-300 bg-white", bordered: "border-gray-300 bg-white",
filled: "border-transparent bg-gray-100", filled: "border-transparent bg-gray-100",
}, },

View File

@@ -104,19 +104,22 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
onChange?.(e); onChange?.(e);
}; };
// 确保 size 有默认值
const actualSize = size ?? "md";
// 滑块大小 // 滑块大小
const thumbSize = { const thumbSize = {
sm: "h-3.5 w-3.5", sm: "h-3.5 w-3.5",
md: "h-4 w-4", md: "h-4 w-4",
lg: "h-5 w-5", lg: "h-5 w-5",
}[size]; }[actualSize];
// 滑块位移 // 滑块位移
const thumbTranslate = { const thumbTranslate = {
sm: isChecked ? "translate-x-4" : "translate-x-0.5", sm: isChecked ? "translate-x-4" : "translate-x-0.5",
md: isChecked ? "translate-x-5" : "translate-x-0.5", md: isChecked ? "translate-x-5" : "translate-x-0.5",
lg: isChecked ? "translate-x-6" : "translate-x-0.5", lg: isChecked ? "translate-x-6" : "translate-x-0.5",
}[size]; }[actualSize];
const renderSwitch = () => ( const renderSwitch = () => (
<div className="relative inline-block"> <div className="relative inline-block">

View File

@@ -27,7 +27,7 @@ import { cn } from "@/design-system/lib/utils";
*/ */
const textareaVariants = cva( const textareaVariants = cva(
// 基础样式 // 基础样式
"flex w-full rounded-xl border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none", "flex w-full rounded-md border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -98,13 +98,16 @@ export function Badge({
info: "bg-info-500", info: "bg-info-500",
}; };
// 确保 variant 有默认值
const actualVariant = variant ?? "default";
return ( return (
<div className={cn(badgeVariants({ variant, size, dot }), className)} {...props}> <div className={cn(badgeVariants({ variant: actualVariant, size, dot }), className)} {...props}>
{dot && ( {dot && (
<span <span
className={cn( className={cn(
"h-2 w-2 rounded-full", "h-2 w-2 rounded-full",
dotColor || dotColors[variant] dotColor || dotColors[actualVariant]
)} )}
/> />
)} )}

View File

@@ -95,7 +95,7 @@ export function Divider({
<div <div
className={cn(dividerVariants({ variant, orientation }), className)} className={cn(dividerVariants({ variant, orientation }), className)}
role="separator" role="separator"
aria-orientation={orientation} aria-orientation={orientation as "horizontal" | "vertical"}
{...props} {...props}
/> />
); );

View File

@@ -34,7 +34,7 @@ import { cn } from "@/design-system/lib/utils";
*/ */
const alertVariants = cva( const alertVariants = cva(
// 基础样式 // 基础样式
"rounded-xl border-2 px-4 py-3 shadow-sm transition-all duration-250", "rounded-lg border-2 px-4 py-3 shadow-sm transition-all duration-250",
{ {
variants: { variants: {
variant: { variant: {
@@ -127,6 +127,9 @@ export function Alert({
if (!visible) return null; if (!visible) return null;
// 确保 variant 有默认值
const actualVariant = variant ?? "info";
// 图标颜色 // 图标颜色
const iconColors = { const iconColors = {
info: "text-info-500", info: "text-info-500",
@@ -137,14 +140,14 @@ export function Alert({
return ( return (
<div <div
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant: actualVariant }), className)}
role="alert" role="alert"
{...props} {...props}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* 图标 */} {/* 图标 */}
<div className={cn("shrink-0", iconColors[variant])}> <div className={cn("shrink-0", iconColors[actualVariant])}>
{icon || defaultIcons[variant]} {icon || defaultIcons[actualVariant]}
</div> </div>
{/* 内容 */} {/* 内容 */}

View File

@@ -102,7 +102,8 @@ export function Progress({
warning: "bg-warning-500", warning: "bg-warning-500",
error: "bg-error-500", error: "bg-error-500",
}; };
return colors[variant]; const actualVariant = variant ?? "default";
return colors[actualVariant];
}; };
// 格式化标签 // 格式化标签
@@ -175,7 +176,7 @@ export function CircularProgress({
warning: "#f59e0b", warning: "#f59e0b",
error: "#ef4444", error: "#ef4444",
}; };
const strokeColor = colors[variant]; const strokeColor = colors[variant ?? "default"];
return ( return (
<div className={cn("inline-flex items-center justify-center", className)}> <div className={cn("inline-flex items-center justify-center", className)}>

View File

@@ -125,6 +125,7 @@ export interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonE
value: string; value: string;
children: React.ReactNode; children: React.ReactNode;
variant?: "line" | "enclosed" | "soft"; variant?: "line" | "enclosed" | "soft";
"data-state"?: string;
} }
const triggerVariants = cva( const triggerVariants = cva(
@@ -148,14 +149,15 @@ export function TabsTrigger({
children, children,
variant = "line", variant = "line",
className, className,
"data-state": dataState,
...props ...props
}: TabsTriggerProps) { }: TabsTriggerProps) {
return ( return (
<button <button
type="button" type="button"
role="tab" role="tab"
aria-selected={props["data-state"] === "active"} aria-selected={dataState === "active"}
data-state={props["data-state"]} data-state={dataState}
className={cn(triggerVariants({ variant }), className)} className={cn(triggerVariants({ variant }), className)}
{...props} {...props}
> >
@@ -170,21 +172,24 @@ export function TabsTrigger({
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> { export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string; value: string;
children: React.ReactNode; children: React.ReactNode;
"data-state"?: string;
} }
export function TabsContent({ export function TabsContent({
value, value,
children, children,
className, className,
"data-state": dataState,
...props ...props
}: TabsContentProps) { }: TabsContentProps) {
if (value !== props["data-state"]) return null; if (value !== dataState) return null;
return ( return (
<div <div
role="tabpanel" role="tabpanel"
className={cn("mt-4 focus:outline-none", className)} className={cn("mt-4 focus:outline-none", className)}
tabIndex={0} tabIndex={0}
data-state={dataState}
{...props} {...props}
> >
{children} {children}

View File

@@ -105,7 +105,7 @@ export function Modal({
{/* 模态框内容 */} {/* 模态框内容 */}
<div <div
className={cn( className={cn(
"relative z-10 w-full bg-white rounded-2xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col", "relative z-10 w-full bg-white rounded-lg shadow-2xl max-h-[90vh] overflow-hidden flex flex-col",
sizeClasses[size], sizeClasses[size],
className className
)} )}

View File

@@ -1,233 +0,0 @@
import type { Config } from "tailwindcss";
/**
* Tailwind CSS 配置
*
* 集成 Design System 设计令牌到 Tailwind 工具类
*/
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
// 颜色系统
colors: {
// 主色 - Teal
primary: {
50: '#f0f9f8',
100: '#e0f2f0',
200: '#bce6e1',
300: '#8dd4cc',
400: '#5ec2b7',
500: '#35786f',
600: '#2a605b',
700: '#1f4844',
800: '#183835',
900: '#122826',
950: '#0a1413',
},
// 中性色
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
// 语义色 - Success
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
// 语义色 - Warning
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03',
},
// 语义色 - Error
error: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
// 语义色 - Info
info: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
// 语义别名
background: 'var(--background)',
foreground: 'var(--foreground)',
},
// 间距系统(基于 8pt 网格)
spacing: {
18: '4.5rem',
88: '22rem',
128: '32rem',
},
// 字体家族
fontFamily: {
sans: [
'var(--font-geist-sans)',
'-apple-system',
'BlinkMacSystemFont',
'system-ui',
'sans-serif',
],
mono: [
'var(--font-geist-mono)',
'ui-monospace',
'SFMono-Regular',
'Monaco',
'Consolas',
'monospace',
],
},
// 字体大小和行高
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
'7xl': ['4.5rem', { lineHeight: '1' }],
'8xl': ['6rem', { lineHeight: '1' }],
'9xl': ['8rem', { lineHeight: '1' }],
},
// 字重
fontWeight: {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
// 圆角
borderRadius: {
sm: '0.125rem',
DEFAULT: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
},
// 阴影
boxShadow: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)',
// 语义阴影
'primary': '0 4px 14px 0 rgba(53, 120, 111, 0.39)',
'success': '0 4px 14px 0 rgba(34, 197, 94, 0.39)',
'warning': '0 4px 14px 0 rgba(245, 158, 11, 0.39)',
'error': '0 4px 14px 0 rgba(239, 68, 68, 0.39)',
'info': '0 4px 14px 0 rgba(59, 130, 246, 0.39)',
},
// 容器最大宽度
maxWidth: {
'xs': '20rem',
'sm': '24rem',
'md': '28rem',
'lg': '32rem',
'xl': '36rem',
'2xl': '42rem',
'3xl': '48rem',
'4xl': '56rem',
'5xl': '64rem',
'6xl': '72rem',
'7xl': '80rem',
'8xl': '88rem',
},
// Z-index 层级
zIndex: {
dropdown: 1000,
sticky: 1020,
fixed: 1030,
modalBackdrop: 1040,
modal: 1050,
popover: 1060,
tooltip: 1070,
},
// 动画时长
transitionDuration: {
'250': '250ms',
'350': '350ms',
},
// 断点
screens: {
'xs': '475px',
},
},
},
plugins: [],
};
export default config;