Initial commit

This commit is contained in:
2026-02-25 15:22:23 +08:00
commit c7138dab9e
84 changed files with 14690 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
name: 发布 Docker 镜像
on:
release:
types: [published]
jobs:
push_to_registry:
name: 推送 Docker 镜像到 Docker Hub
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: 检出仓库代码
uses: https://git.kakunet.top/actions/checkout@v4
- name: 登录到 Gitea Docker Hub
uses: https://git.kakunet.top/docker/login-action@v3
with:
registry: git.yinlihupo.cn
username: { secrets.DOCKER_USERNAME }
password: { secrets.DOCKER_PASSWORD }
- name: 提取 Docker 元数据(标签、标记)
id: meta
uses: https://git.kakunet.top/docker/metadata-action@v5
with:
images: git.yinlihupo.cn/warmonion/intelligent-daily-report-system
- name: 构建并推送后端 Docker 镜像
uses: https://git.kakunet.top/docker/build-push-action@v5
with:
context: .
file: ./dockerfile
push: true
tags: { steps.meta.outputs.tags }
labels: { steps.meta.outputs.labels }

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
wheels/
*.egg-info
# Virtual environments
.venv
.env
# prod file
.sqlite
.DS_Store

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

0
README.md Normal file
View File

105
config.py Normal file
View File

@@ -0,0 +1,105 @@
import os
from pathlib import Path
from types import SimpleNamespace # 导入SimpleNamespace
from typing import Any, Dict
import yaml
from dotenv import load_dotenv
from loguru import logger
class EnvConfig:
def __init__(self):
# 加载环境变量
if os.path.exists(".env"):
if load_dotenv(".env"):
logger.info("[激活配置] .env 配置加载成功 ✅")
else:
# 将 exit(1) 更改为 warning避免程序直接退出
logger.warning(
"[激活配置] 环境变量加载失败,请检查 .env 文件内容是否正确"
)
else:
logger.warning("[激活配置] .env 文件不存在,将不加载环境变量")
def _parse_bool(self, value):
"""将字符串形式的布尔值转换为布尔类型"""
if isinstance(value, str):
if value.lower() == "true":
return True
elif value.lower() == "false":
return False
return value
def __getattr__(self, name):
value = os.getenv(name)
if value is not None:
return self._parse_bool(value)
default_name = f"DEFAULT_{name}"
default_value = os.getenv(default_name)
if default_value is not None:
return self._parse_bool(default_value)
return None
def get(self, name, default=None):
value = os.getenv(name)
if value is not None:
return self._parse_bool(value)
return default
class YamlConfig:
def __init__(self, yaml_file: str = "config.yaml"):
self._yaml_file = Path(yaml_file)
self._load_settings()
def _load_settings(self) -> None:
if not self._yaml_file.exists():
raise FileNotFoundError(f"[激活配置] 配置文件 {self._yaml_file} 不存在")
with open(self._yaml_file, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f) or {}
self._set_attributes(config_data)
logger.info("[激活配置] config.yaml 配置加载成功 ✅")
def _set_attributes(self, config_data: Dict[str, Any]) -> None:
# 使用 SimpleNamespace 更好地处理嵌套配置,使其可以通过属性访问
for key, value in config_data.items():
if isinstance(value, dict):
setattr(self, key, SimpleNamespace(**value))
else:
setattr(self, key, value)
# 移除 _from_dict 方法,因为 SimpleNamespace 可以直接从字典创建
def __repr__(self) -> str:
attrs = []
for key in sorted(self.__dict__.keys()):
if not key.startswith("_"):
value = getattr(self, key)
attrs.append(f"{key}={repr(value)}")
return f"Settings({', '.join(attrs)})"
def reload(self) -> None:
if self._yaml_file is None:
raise RuntimeError("无法重新加载,此实例是从字典创建的")
self._load_settings()
# 创建配置实例
env_config = EnvConfig()
yaml_config = None
try:
yaml_config = YamlConfig()
except FileNotFoundError:
logger.warning("[激活配置] 配置文件 config.yaml 不存在,将不加载配置")
class Setting:
def __init__(self):
self.env = env_config
self.config = yaml_config
setting = Setting()

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
call-analysis:
image: git.yinlihupo.cn/warmonion/intelligent-daily-report-system:latest
# 读取环境变量
environment:
- TZ=Asia/Shanghai
ports:
- "8000:8000"

21
dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM docker.mirrors.kakunet.top/library/python:3.12-slim
WORKDIR /app
# 安装uv包管理器使用清华源
RUN pip3 install uv -i https://mirrors.aliyun.com/pypi/simple
# 复制依赖文件
COPY pyproject.toml uv.lock ./
# 安装依赖
RUN uv sync -i https://mirrors.aliyun.com/pypi/simple
# 复制项目代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "debug"]

2
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
src/components/animate-ui/**
src/components/ui/**

26
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env*
opt
.marscode

0
frontend/README.md Normal file
View File

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@manifest": "https://ui.manifest.build/r/{name}.json"
}
}

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist", "src/components/animate-ui", "src/components/ui"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN" data-theme="shadcn">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>intelligent-daily-report-system</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

109
frontend/package.json Normal file
View File

@@ -0,0 +1,109 @@
{
"name": "hf-analysis-dash",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"generate": "npx openapi-typescript http://127.0.0.1:8000/openapi.json -o ./src/path/schema.d.ts",
"preview": "vite preview"
},
"dependencies": {
"@ag-ui/client": "^0.0.42",
"@floating-ui/react": "^0.27.16",
"@icons-pack/react-simple-icons": "^13.8.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"@uidotdev/usehooks": "^2.4.1",
"ahooks": "^3.9.5",
"ai": "^5.0.98",
"cally": "^0.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"dom-to-image": "^2.6.0",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"harden-react-markdown": "^1.1.5",
"i": "^0.3.7",
"katex": "^0.16.25",
"lucide-react": "^0.544.0",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"npm": "^11.6.2",
"openapi-fetch": "^0.14.0",
"openapi-react-query": "^0.5.0",
"pikaday": "^1.8.2",
"preline": "^3.2.3",
"radix-ui": "^1.4.3",
"react": "^19.1.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.0",
"react-tailwindcss-datepicker": "^2.0.0",
"react-use-measure": "^2.1.7",
"recharts": "^3.2.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.15.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.1",
"xlsx": "^0.18.5",
"zustand": "^5.0.8"
},
"devDependencies": {
"@catppuccin/daisyui": "^2.1.1",
"@eslint/js": "^9.36.0",
"@iconify/tailwind4": "^1.1.0",
"@shikijs/transformers": "^3.15.0",
"@types/dom-to-image": "^2.6.7",
"@types/file-saver": "^2.0.7",
"@types/node": "^24.10.1",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.14",
"daisyui": "^5.1.25",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"lucide": "^0.548.0",
"openapi-typescript": "^7.9.1",
"react-uselocalstoragelistener": "^1.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^7.1.7"
}
}

7954
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M416 425.6l78.4 44.8c9.6 6.4 16 16 16 27.2v91.2c0 11.2-6.4 22.4-16 27.2L416 662.4c-9.6 6.4-22.4 6.4-32 0l-78.4-44.8c-9.6-6.4-16-16-16-27.2v-91.2c0-11.2 6.4-22.4 16-27.2l78.4-44.8c9.6-8 22.4-8 32-1.6z" fill="#2F4BFF" /><path d="M643.2 267.2c-3.2-1.6-4.8-1.6-8 0l-67.2 38.4c-3.2 1.6-3.2 4.8-3.2 6.4v76.8c0 3.2 1.6 4.8 3.2 6.4l67.2 38.4c3.2 1.6 4.8 1.6 8 0l67.2-38.4c3.2-1.6 3.2-4.8 3.2-6.4v-76.8c0-3.2-1.6-4.8-3.2-6.4l-67.2-38.4z m9.6-12.8l67.2 38.4c8 4.8 12.8 12.8 12.8 20.8v76.8c0 8-4.8 16-12.8 20.8l-67.2 38.4c-8 4.8-16 4.8-24 0l-67.2-38.4c-8-4.8-12.8-12.8-12.8-20.8v-76.8c0-8 4.8-16 12.8-20.8l67.2-38.4c6.4-4.8 16-4.8 24 0zM688 691.2l-67.2 38.4v76.8l67.2 38.4 67.2-38.4v-76.8L688 691.2z m83.2 9.6c9.6 6.4 16 16 16 27.2v76.8c0 11.2-6.4 22.4-16 27.2L704 873.6c-9.6 6.4-22.4 6.4-32 0l-67.2-38.4c-9.6-6.4-16-16-16-27.2v-76.8c0-11.2 6.4-22.4 16-27.2l67.2-38.4c9.6-6.4 22.4-6.4 32 0l67.2 35.2zM176 169.6v44.8l40 22.4 40-22.4v-44.8l-40-22.4-40 22.4zM275.2 144c8 4.8 12.8 12.8 12.8 20.8v54.4c0 8-4.8 16-12.8 20.8l-48 27.2c-8 4.8-16 4.8-24 0l-48-27.2c-6.4-4.8-11.2-12.8-11.2-20.8v-54.4c0-8 4.8-16 12.8-20.8l48-27.2c8-4.8 16-4.8 24 0L275.2 144zM192 777.6l-48 27.2v54.4l48 27.2 48-27.2v-54.4l-48-27.2z m8-14.4l48 27.2c4.8 3.2 8 8 8 14.4v54.4c0 6.4-3.2 11.2-8 14.4l-48 27.2c-4.8 3.2-11.2 3.2-16 0l-48-27.2c-4.8-3.2-8-8-8-14.4v-54.4c0-6.4 3.2-11.2 8-14.4l48-27.2c4.8-3.2 11.2-3.2 16 0z" fill="#050D42" /><path d="M403.2 776l-62.4 62.4c-1.6 1.6-3.2 1.6-6.4 1.6h-88c-4.8 0-8-3.2-8-8s3.2-8 8-8h84.8l59.2-59.2v-68.8c0-4.8 3.2-8 8-8s8 3.2 8 8v64H576c4.8 0 8 3.2 8 8s-3.2 8-8 8H403.2z m-11.2-436.8l-108.8-94.4c-3.2-3.2-3.2-8-1.6-11.2 3.2-3.2 8-3.2 11.2-1.6l110.4 94.4H528c4.8 0 8 3.2 8 8s-3.2 8-8 8h-120V400c0 4.8-3.2 8-8 8s-8-3.2-8-8v-60.8zM800 728c-4.8 0-8-3.2-8-8s3.2-8 8-8h88c4.8 0 8 3.2 8 8s-3.2 8-8 8H800z m-49.6-435.2c-3.2 3.2-8 3.2-11.2 1.6-3.2-3.2-3.2-8-1.6-11.2l96-112c3.2-3.2 8-3.2 11.2-1.6 3.2 3.2 3.2 8 1.6 11.2l-96 112zM160 504c-4.8 0-8-3.2-8-8s3.2-8 8-8h112c4.8 0 8 3.2 8 8s-3.2 8-8 8h-112z m536 144c0 4.8-3.2 8-8 8s-8-3.2-8-8V544c0-4.8 3.2-8 8-8s8 3.2 8 8v104z" fill="#050D42" /></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

9
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
function App() {
return (
<div>App</div>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,193 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
);
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,185 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,61 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
// Simple event listener hook for document click events
function useEventListener(eventName: string, handler: (event: MouseEvent) => void) {
const savedHandler = React.useRef(handler);
React.useEffect(() => { savedHandler.current = handler; }, [handler]);
React.useEffect(() => {
if (typeof window === "undefined") return;
const eventListener = (event: Event) => savedHandler.current(event as MouseEvent);
document.addEventListener(eventName, eventListener);
return () => document.removeEventListener(eventName, eventListener);
}, [eventName]);
}
/**
* Custom hook that handles click events anywhere on the document
* @param handler The function to be called when a click event is detected anywhere on the document
*/
export function useClickAnyWhere(handler: (event: MouseEvent) => void): void {
useEventListener('click', event => {
handler(event);
});
}

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
interface CommonControlledStateProps<T> {
value?: T;
defaultValue?: T;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useControlledState<T, Rest extends any[] = []>(
props: CommonControlledStateProps<T> & {
onChange?: (value: T, ...args: Rest) => void;
},
): readonly [T, (next: T, ...args: Rest) => void] {
const { value, defaultValue, onChange } = props;
const [state, setInternalState] = React.useState<T>(
value !== undefined ? value : (defaultValue as T),
);
React.useEffect(() => {
if (value !== undefined) setInternalState(value);
}, [value]);
const setState = React.useCallback(
(next: T, ...args: Rest) => {
setInternalState(next);
onChange?.(next, ...args);
},
[onChange],
);
return [state, setState] as const;
}

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import { useInView, type UseInViewOptions } from 'motion/react';
interface UseIsInViewOptions {
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions['margin'];
}
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {},
) {
const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return { ref: localRef, isInView };
}
export { useIsInView, type UseIsInViewOptions };

120
frontend/src/index.css Normal file
View File

@@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
function getStrictContext<T>(
name?: string,
): readonly [
({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => React.JSX.Element,
() => T,
] {
const Context = React.createContext<T | undefined>(undefined);
const Provider = ({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => <Context.Provider value={value}>{children}</Context.Provider>;
const useSafeContext = () => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);
}
return ctx;
};
return [Provider, useSafeContext] as const;
}
export { getStrictContext };

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
function useURL() {
const [params, setParams] = useState({});
useEffect(() => {
const handleUrlChange = () => {
const searchParams = new URLSearchParams(window.location.search);
const newParams = {};
searchParams.forEach((value, key) => {
(newParams as Record<string, string>)[key] = value;
});
setParams(newParams);
};
window.addEventListener("popstate", handleUrlChange);
handleUrlChange(); // 初始解析
return () => {
window.removeEventListener("popstate", handleUrlChange);
};
}, []);
const updateParams = (newParams: Record<string, string>) => {
const url = new URL(window.location.origin);
Object.entries(newParams).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.pushState({}, "", url);
setParams(newParams);
};
return { params, setParams: updateParams };
}
export default useURL;

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

17
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { Toaster } from "react-hot-toast";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>
<div className="z-[99999]">
<Toaster />
</div>
</StrictMode>,
);

View File

@@ -0,0 +1,55 @@
import createFetchClient from "openapi-fetch";
import type { Middleware } from "openapi-fetch";
import type { paths } from "./schema";
import createClient from "openapi-react-query";
import toast from "react-hot-toast";
const createInterceptor = () => {
const intercept: Middleware = {
async onResponse({ response }) {
const responseClone = response.clone();
const { status } = responseClone;
if (status === 0) {
toast.error("网络连接失败,请检查网络连接");
return response;
}
if (status >= 400) {
const body = await responseClone.json();
if (body?.detail) {
toast.error(body?.detail);
}
if (status === 408) {
toast.error("请求超时,请稍后重试");
return response;
}
}
if (status >= 200 && status < 300) {
const body = await responseClone.json();
if (body?.message) {
toast.success(body?.message);
}
}
return response;
},
async onRequest({ request }) {
const user = JSON.parse(localStorage.getItem("userInfo") || "{}");
if (user?.token) {
request.headers.set("Authorization", `Bearer ${user.token}`);
}
},
};
return intercept;
};
const client = createFetchClient<paths>({
baseUrl: import.meta.env.VITE_BASE_URL || "/",
});
// 注册中间件
client.use(createInterceptor());
const queryClient = createClient(client);
export default queryClient;
export { client };

418
frontend/src/path/schema.d.ts vendored Normal file
View File

@@ -0,0 +1,418 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 获取API统计信息
* @description 获取API统计信息
*/
get: operations["get_stats_api_stats_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/profiler": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Dashboard
* @description Serve the profiler dashboard.
*/
get: operations["dashboard_profiler_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/profiler/api/profiles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Profiles
* @description Return recent profile data as JSON.
*/
get: operations["get_profiles_profiler_api_profiles_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/profiler/api/dashboard-data": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Dashboard Data
* @description Return pre-calculated data for the dashboard.
*/
get: operations["get_dashboard_data_profiler_api_dashboard_data_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/profiler/api/profile/{profile_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Profile
* @description Return a specific profile by ID.
*/
get: operations["get_profile_profiler_api_profile__profile_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Run Agent */
post: operations["run_agent__post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/shaoshupai": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Shaoshupai */
get: operations["get_shaoshupai_shaoshupai_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** ShaoshupaiArticleModel */
ShaoshupaiArticleModel: {
/** Id */
id: number;
/** Title */
title: string;
/**
* Banner
* @default
*/
banner: string;
/**
* Summary
* @default
*/
summary: string;
/**
* Comment Count
* @default 0
*/
comment_count: number;
/**
* Like Count
* @default 0
*/
like_count: number;
/**
* View Count
* @default 0
*/
view_count: number;
/**
* Free
* @default false
*/
free: boolean;
/**
* Post Type
* @default 0
*/
post_type: number;
/**
* Important
* @default 0
*/
important: number;
/**
* Released Time
* @default 0
*/
released_time: number;
/**
* Morning Paper Title
* @default []
*/
morning_paper_title: unknown[];
/**
* Advertisement Url
* @default
*/
advertisement_url: string;
/**
* Series
* @default []
*/
series: unknown[];
};
/** ShaoshupaiArticlesModel */
ShaoshupaiArticlesModel: {
/** Articles */
articles: components["schemas"]["ShaoshupaiArticleModel"][];
};
/** ShaoshupaiResponseModel */
ShaoshupaiResponseModel: {
data: components["schemas"]["ShaoshupaiArticlesModel"];
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
get_stats_api_stats_get: {
parameters: {
query?: {
ip?: string;
path?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
dashboard_profiler_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
get_profiles_profiler_api_profiles_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
get_dashboard_data_profiler_api_dashboard_data_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
get_profile_profiler_api_profile__profile_id__get: {
parameters: {
query?: never;
header?: never;
path: {
profile_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
run_agent__post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
get_shaoshupai_shaoshupai_get: {
parameters: {
query: {
search_term: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ShaoshupaiResponseModel"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@@ -0,0 +1,3 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin("frappe", "lavender");

View File

@@ -0,0 +1,3 @@
import { createCatppuccinPlugin } from '@catppuccin/daisyui'
export default createCatppuccinPlugin('latte')

View File

@@ -0,0 +1,12 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin("macchiato", {
primary: "lavender",
"primary-content": "mantle",
secondary: "surface0",
"secondary-content": "text",
accent: "rosewater",
"accent-content": "mantle",
neutral: "overlay1",
"neutral-content": "mantle",
});

View File

@@ -0,0 +1,18 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin(
"mocha",
{
"--radius-selector": "0.25rem",
"--radius-field": "0.5rem",
"--radius-box": "0.5rem",
"--size-field": "0.25rem",
"--size-selector": "0.25rem",
"--border": "1px",
"--depth": true,
"--noise": false,
},
{
prefersdark: true,
},
);

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["src/components/animate-ui", "src/components/ui"]
}

14
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["src/components/animate-ui", "src/components/ui"]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
watch: {
ignored: ['**/node_modules/**', '**/dist/**' , '**/opt/**'],
},
proxy: {
"/oauth2": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});

3
handler/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .exception import install
__all__ = ["install"]

31
handler/exception.py Normal file
View File

@@ -0,0 +1,31 @@
import http
from fastapi import FastAPI, HTTPException, Request
from pydantic import ValidationError
from fastapi.responses import JSONResponse
from uvicorn.server import logger
exceptions = [Exception, HTTPException, ValidationError]
async def general_exception_handler(request: Request, e: Exception):
logger.error(f"[异常处理] 发生异常: {e}")
status_code = http.HTTPStatus.INTERNAL_SERVER_ERROR
detail = str(e)
message = "发生了一些错误"
if isinstance(e, HTTPException):
status_code = e.status_code
message = e.detail
return JSONResponse(
status_code=status_code,
content={"detail": detail, "message": message},
)
def install(app: FastAPI):
logger.info("[异常处理] 开始加载异常处理器")
for exception in exceptions:
app.add_exception_handler(exception, general_exception_handler)
logger.info("[异常处理] 异常处理器加载完成")

42
lifespan.py Normal file
View File

@@ -0,0 +1,42 @@
from contextlib import asynccontextmanager
from typing import Callable
from fastapi import FastAPI
from plugin import PluginManager
from uvicorn.server import logger
def active_config():
logger.info("[激活配置] 加载配置 ⚙️")
from config import setting # noqa
def import_router(app: FastAPI):
logger.info("[导入路由] 开始导入路由 🛣️")
from router import router
app.include_router(router)
logger.info("[导入路由] 路由导入完成 ✅")
def install_plugins_after_started(app: FastAPI):
logger.info("[安装插件] 开始安装插件 📦")
from plugin import SpaProxy
plugin_manager = PluginManager(app)
# 安装插件
plugin_manager.register_plugin(SpaProxy(app, dist="frontend/dist"))
logger.info("[安装插件] 插件安装完成 ✅")
@asynccontextmanager
async def lifespan(app: FastAPI, func: Callable = lambda: None):
logger.info("[生命周期] 应用启动 🚀")
active_config()
import_router(app)
install_plugins_after_started(app)
func()
yield
logger.info("[生命周期] 应用关闭 🔧✅")

23
main.py Normal file
View File

@@ -0,0 +1,23 @@
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from handler.exception import install as exception_install
from lifespan import lifespan
from plugin import CORSMiddleware, Monitor, PluginManager, Profiler
from utils.start import start
app: FastAPI = start(lambda: FastAPI(lifespan=lifespan))
# 添加 GZip 中间件
app.add_middleware(
GZipMiddleware,
minimum_size=1000, # 仅压缩大于 1000 字节的响应
compresslevel=5, # 压缩级别,范围为 1-9默认值为 9
)
# 安装异常处理中间件
exception_install(app)
# 安装需要启动后才能安装插件
plugin_manager = PluginManager(app)
plugin_manager.register_plugin(CORSMiddleware(app))
plugin_manager.register_plugin(Monitor(app))
plugin_manager.register_plugin(Profiler(app))

17
plugin/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
from .base import PluginManager
from .cors import CORSMiddleware
from .monitor import Monitor
from .profiler import Profiler
from .spa import SpaProxy
from .user import AuthUser, UserManager, UserSource
__all__ = [
"PluginManager",
"Monitor",
"SpaProxy",
"CORSMiddleware",
"AuthUser",
"UserManager",
"UserSource",
"Profiler",
]

34
plugin/base.py Normal file
View File

@@ -0,0 +1,34 @@
from fastapi import FastAPI
from uvicorn.server import logger
class Plugin:
def __init__(
self, app: FastAPI, name="Unnamed Plugin", version="1.0.0", *args, **kwargs
):
self.app = app
self.name = name
self.version = version
def install(self):
pass
class PluginManager:
def __init__(self, app: FastAPI):
self.app = app
def register_plugin(self, plugin: Plugin):
plugin_name = getattr(plugin, "name", "Unnamed Plugin")
plugin_version = getattr(plugin, "version", "Unknown Version")
logger.info(f"[插件] Registering plugin: [{plugin_name} {plugin_version}] ")
try:
plugin.install()
logger.info(
f"[插件] Plugin [{plugin_name} {plugin_version}] installed successfully ✅"
)
except Exception as e:
logger.error(
f"[插件] Failed to install plugin [{plugin_name} {plugin_version}]: {e}"
)

3
plugin/cors/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .cors import CorsPlugin as CORSMiddleware
__all__ = ["CORSMiddleware"]

23
plugin/cors/cors.py Normal file
View File

@@ -0,0 +1,23 @@
from fastapi import FastAPI
from plugin.base import Plugin
class CorsPlugin(Plugin):
def __init__(self, app: FastAPI):
self.name = "CorsPlugin"
self.description = "Enable Cross-Origin Resource Sharing (CORS)"
self.version = "1.0.0"
self.app = app
def install(self):
from fastapi.middleware.cors import CORSMiddleware
# 添加CORS中间件解决跨域问题
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

View File

@@ -0,0 +1,3 @@
from .middleware import Monitor
__all__ = ["Monitor"]

View File

@@ -0,0 +1,267 @@
import asyncio
import time
from collections import defaultdict
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from plugin.base import Plugin
from uvicorn.server import logger
class APIMonitorMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app: FastAPI,
*,
entity: object = None,
exclude_paths: Optional[List[str]] = None,
rate_limit_window: int = 60,
):
super().__init__(app)
self.exclude_paths = exclude_paths or []
self.rate_limit_window = rate_limit_window
# 用于存储请求频率数据
self.request_counts = defaultdict(int)
self.request_timestamps = defaultdict(list)
# 存储中间件实例以便后续访问
# app.state.api_monitor = self
entity.middleware = self
# 启动后台清理任务
self.cleanup_task = asyncio.create_task(self._cleanup_old_records())
async def _cleanup_old_records(self):
"""定期清理过期的请求记录"""
try:
while True:
await asyncio.sleep(self.rate_limit_window * 2)
current_time = time.time()
keys_to_remove = []
for key in list(self.request_timestamps.keys()):
# 移除超过时间窗口的时间戳
self.request_timestamps[key] = [
ts
for ts in self.request_timestamps[key]
if current_time - ts < self.rate_limit_window
]
if not self.request_timestamps[key]:
keys_to_remove.append(key)
for key in keys_to_remove:
del self.request_timestamps[key]
if key in self.request_counts:
del self.request_counts[key]
except asyncio.CancelledError:
# 任务被取消时正常退出
pass
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# 检查是否排除该路径
if any(request.url.path.startswith(path) for path in self.exclude_paths):
return await call_next(request)
# 记录开始时间
start_time = time.time()
try:
# 处理请求
response = await call_next(request)
process_time = time.time() - start_time
status_code = response.status_code
# response.headers["X-Process-Time"] = str(process_time)
error = None
except Exception as e:
process_time = time.time() - start_time
response = None
status_code = 500
error = str(e)
# 重新抛出异常以便FastAPI处理
raise
finally:
# 无论是否异常都记录日志
# 更新请求频率统计
await self._update_request_stats(request, status_code)
# 记录请求信息
await self._log_request(
request, status_code, start_time, process_time, error
)
return response if response else Response(status_code=status_code)
async def _update_request_stats(self, request: Request, status_code: int):
"""更新请求频率统计"""
client_ip = request.client.host if request.client else "unknown"
endpoint_key = f"{client_ip}:{request.method}:{request.url.path}"
current_time = time.time()
self.request_timestamps[endpoint_key].append(current_time)
# 计算当前时间窗口内的请求次数
window_start = current_time - self.rate_limit_window
self.request_timestamps[endpoint_key] = [
ts for ts in self.request_timestamps[endpoint_key] if ts >= window_start
]
self.request_counts[endpoint_key] = len(self.request_timestamps[endpoint_key])
async def _log_request(
self,
request: Request,
status_code: int,
start_time: float,
process_time: float = 0,
error: Optional[str] = None,
):
"""记录请求日志"""
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "")
referer = request.headers.get("referer", "")
log_data = {
"timestamp": datetime.fromtimestamp(start_time).isoformat(),
"client_ip": client_ip,
"method": request.method,
"url": str(request.url),
"path": request.url.path,
"query_params": dict(request.query_params),
# "body": await request.json(),
"status_code": status_code,
"process_time_ms": round(process_time * 1000, 2),
"user_agent": user_agent[:200], # 限制长度
"referer": referer[:200],
"content_type": request.headers.get("content-type", ""),
"content_length": int(request.headers.get("content-length", 0)),
"error": error,
}
# 拼接为一条字符串
log_entry = f"耗时:{log_data['process_time_ms']}ms,错误:{error}, 请求参数: {str(log_data['query_params'])}, 请求url: {log_data['url']} "
# 根据状态码决定日志级别
if status_code >= 500:
logger.error(log_entry)
elif status_code >= 400:
logger.error(log_entry)
elif status_code >= 300:
logger.info(log_entry)
elif status_code >= 200:
logger.debug(log_entry)
def get_request_stats(
self, ip: Optional[str] = None, path: Optional[str] = None
) -> Dict[str, Any]:
"""获取请求统计信息"""
stats = {}
current_time = time.time()
for key in list(self.request_counts.keys()):
parts = key.split(":", 2)
if len(parts) < 3:
continue
client_ip, method, endpoint_path = parts
if ip and client_ip != ip:
continue
if path and endpoint_path != path:
continue
# 计算各种统计指标
timestamps = self.request_timestamps.get(key, [])
recent_requests = [
ts for ts in timestamps if current_time - ts < self.rate_limit_window
]
stats[key] = {
"total_requests": len(timestamps),
"current_window_requests": len(recent_requests),
"requests_per_minute": len(recent_requests)
* (60 / self.rate_limit_window),
"last_request_time": datetime.fromtimestamp(max(timestamps)).isoformat()
if timestamps
else None,
"method": method,
"endpoint": endpoint_path,
"client_ip": client_ip,
}
return stats
def get_summary_stats(self) -> Dict[str, Any]:
"""获取汇总统计信息"""
total_requests = sum(
len(timestamps) for timestamps in self.request_timestamps.values()
)
unique_clients = len(
set(key.split(":")[0] for key in self.request_timestamps.keys())
)
unique_endpoints = len(
set(
key.split(":")[2]
for key in self.request_timestamps.keys()
if len(key.split(":")) >= 3
)
)
return {
"total_requests": total_requests,
"unique_clients": unique_clients,
"unique_endpoints": unique_endpoints,
"monitoring_since": datetime.now().isoformat(),
"time_window_seconds": self.rate_limit_window,
}
class Monitor(Plugin):
def __init__(
self, app: FastAPI, exclude_paths: list = None, rate_limit_window: int = 60
):
"""_summary_
Args:
app (FastAPI): _description_
exclude_paths (list, optional): _description_. Defaults to None.
rate_limit_window (int, optional): _description_. Defaults to 60.
"""
logger.info("[监控插件] 加载监控插件 🔧")
self.app = app
self.exclude_paths = exclude_paths
self.rate_limit_window = rate_limit_window
self.middleware = None
self.name = "Monitor"
self.version = "1.0.0"
async def get_stats(self, ip: str = None, path: str = None):
"""获取API统计信息"""
if self.middleware:
return {
"summary": self.middleware.get_summary_stats(),
"detailed_stats": self.middleware.get_request_stats(ip, path),
}
return {"error": "Monitor middleware not initialized"}
def install(self):
self.app.add_middleware(
APIMonitorMiddleware,
entity=self,
exclude_paths=self.exclude_paths,
rate_limit_window=self.rate_limit_window,
)
self.app.add_api_route(
path="/api/stats",
endpoint=self.get_stats,
methods=["GET"],
summary="获取API统计信息",
description="获取API统计信息",
)
logger.info("[监控插件] 监控插件已加载 ✅")

View File

@@ -0,0 +1,3 @@
from .profiler import Profiler
__all__ = ["Profiler"]

View File

@@ -0,0 +1,15 @@
from fastapi import FastAPI
from fastapi_profiler import Profiler as FastapiProfilerMiddleware
from plugin.base import Plugin
class Profiler(Plugin):
def __init__(self, app: FastAPI, dashboard_path: str = "/profiler"):
self.app = app
self.dashboard_path = dashboard_path
self.name = "Profiler"
self.version = "1.0.0"
def install(self):
FastapiProfilerMiddleware(self.app, self.dashboard_path)

3
plugin/spa/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .spa_proxy import SpaProxy
__all__ = ["SpaProxy"]

50
plugin/spa/spa_proxy.py Normal file
View File

@@ -0,0 +1,50 @@
import os
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from plugin.base import Plugin
from uvicorn.server import logger
class SpaProxy(Plugin):
def __init__(self, app: FastAPI, dist: str = "dist"):
self.app = app
self.dist = dist
self.name = "SPA Proxy"
self.version = "1.0.0"
def install(self):
# --- [新增] 挂载前端静态文件 --
frontend_dir = os.path.join(os.getcwd(), self.dist)
if not os.path.isdir(frontend_dir):
return
# 1. 挂载静态资源(如 /assets/...
assets_dir = os.path.join(frontend_dir, "assets")
if os.path.isdir(assets_dir):
self.app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
# 2. 【关键】兜底路由:处理所有未被 API 匹配的路径(支持前端路由如 /analysis
@self.app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
# 如果请求的是 API 路径,不应走到这里(因为 API 路由已先注册)
index_path = os.path.join(frontend_dir, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Frontend not found")
# 3. 显式处理根路径(可选,但更清晰)
@self.app.get("/")
async def root():
return await serve_frontend("")
# self.app.add_api_route(
# "/{full_path:path}",
# serve_frontend,
# methods=["GET"],
# response_class=FileResponse,
# )
logger.info(f"前端静态文件挂载: {frontend_dir}")

3
plugin/user/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .user_manager import AuthUser, UserManager, UserSource
__all__ = ["AuthUser", "UserManager", "UserSource"]

190
plugin/user/user_manager.py Normal file
View File

@@ -0,0 +1,190 @@
import datetime
import secrets
import uuid
from typing import Generic, TypedDict, TypeVar
import jwt
from fastapi import HTTPException, Request
from pydantic import BaseModel
from typing_extensions import List
ID_TYPE = str | int
class AuthUser(BaseModel):
id: ID_TYPE
def __init__(self, *args, **kwargs):
if "id" not in kwargs:
kwargs["id"] = str(uuid.uuid4())
super().__init__(*args, **kwargs)
T = TypeVar("T")
class Paginate(TypedDict, Generic[T]):
items: list[T]
total: int
pages: int
current_page: int
per_page: int
class UserSource(Generic[T]):
def __init__(self, *args, **kwargs) -> None:
self._data: dict[ID_TYPE, T] = dict[ID_TYPE, T](*args, **kwargs)
def __getitem__(self, key: ID_TYPE) -> T:
return self._data[key]
def __setitem__(self, key: ID_TYPE, value: T) -> None:
self._data[key] = value
def __delitem__(self, key: ID_TYPE) -> None:
del self._data[key]
def __contains__(self, key: ID_TYPE) -> bool:
return key in self._data
def keys(self) -> List[ID_TYPE]:
return list(self._data.keys())
def get(self, key: ID_TYPE, default: T | None = None) -> T | None:
return self._data.get(key, default)
def paginate(self, page: int = 1, per_page: int = 10) -> Paginate[T]:
"""分页获取用户数据
Args:
page: 页码从1开始
per_page: 每页数量
Returns:
包含分页信息的字典,包括:
- items: 当前页的数据列表
- total: 总数据量
- pages: 总页数
- current_page: 当前页码
- per_page: 每页数量
"""
total = len(self._data)
pages = (total + per_page - 1) // per_page if per_page > 0 else 0
if page < 1:
page = 1
if page > pages and pages > 0:
page = pages
start = (page - 1) * per_page
end = start + per_page
items = list(self._data.values())[start:end]
return {
"items": items,
"total": total,
"pages": pages,
"current_page": page,
"per_page": per_page,
}
def __repr__(self) -> str:
return f"UserSource({self._data})"
TUser = TypeVar("TUser", bound=AuthUser)
class UserManager(Generic[TUser]):
users: UserSource[TUser] = UserSource()
secret_key: str = secrets.token_urlsafe(32)
expiration_time: int = 1440
algorithm: str = "HS256"
token_type: str = "Bearer"
auth_header_field: str = "Authorization"
def get_user(self, user_id: ID_TYPE) -> TUser | None:
return self.users.get(user_id, None)
def add_user(self, user: TUser) -> bool:
if user.id in self.users.keys():
return False
self.users[user.id] = user
return True
def delete_user(self, user_id: ID_TYPE) -> bool:
if user_id not in self.users:
return False
del self.users[user_id]
return True
def update_user(self, user: TUser) -> bool:
if user.id not in self.users:
return False
self.users[user.id] = user
return True
def create_token(self, user: TUser) -> str:
payload = {
**user.model_dump(),
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(minutes=self.expiration_time),
}
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
return token
def paginate_users(self, page: int = 1, per_page: int = 10) -> Paginate[TUser]:
"""分页查询用户
Args:
page: 页码从1开始
per_page: 每页数量
Returns:
包含分页信息的字典
"""
return self.users.paginate(page=page, per_page=per_page)
def verify_token(self, token: str) -> TUser | None:
import jwt
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
user_id = payload.get("id")
if user_id is None:
return None
return self.get_user(user_id)
except jwt.ExpiredSignatureError:
raise jwt.ExpiredSignatureError
except jwt.InvalidTokenError:
raise jwt.InvalidTokenError
def refresh_token(self, token: str) -> str | None:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
user_id = payload.get("id")
if user_id is None:
return None
user = self.get_user(user_id)
if user is None:
return None
return self.create_token(user)
def fastapi_get_user(self, request: Request) -> TUser | None:
"""FastAPI dependency to get current user from token"""
auth_header = request.headers.get(self.auth_header_field)
if not auth_header:
raise HTTPException(status_code=401, detail="Invalid token")
parts = auth_header.split()
if len(parts) != 2 or parts[0] != self.token_type:
raise HTTPException(status_code=401, detail="Invalid token")
token = parts[1]
try:
user = self.verify_token(token)
except Exception as e:
raise HTTPException(status_code=401, detail=f"Invalid token {e}")
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user

51
pyproject.toml Normal file
View File

@@ -0,0 +1,51 @@
[project]
name = "intelligent-daily-report-system"
version = "0.1.0"
description = ""
authors = [
{ name = "WuJiaLiang"},
{ name = "Tordor", email = "3262978839@qq.com" },
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"casbin>=1.43.0",
"fastapi[standard]>=0.116.1",
"fastapi-utils>=0.8.0",
"motor>=3.7.1",
"pyjwt>=2.10.1",
"pyminio>=0.3.1",
"pytest>=8.4.1",
"python-dotenv>=1.1.1",
"sqlmodel>=0.0.24",
"uvicorn>=0.35.0",
"apscheduler>=3.11.0",
"pickledb>=1.3.2",
"aiocache>=0.12.3",
"pyfiglet>=1.0.4",
"toml>=0.10.2",
"pinyin>=0.4.0",
"granian>=2.5.6",
"loguru>=0.7.3",
"redis<6.0.1",
"pymysql>=1.1.2",
"fastapi-oauth20>=0.0.2",
"fastapi-profiler-lite>=0.3.2",
"taskiq>=0.11.20",
"taskiq-redis>=1.1.2",
"python-multipart>=0.0.20",
"pydantic-ai>=0.0.2",
"pydantic-ai-slim[ag-ui,openai,openrouter]>=1.22.0",
"pydantic>=2.12.4",
"griffe>=1.15.0",
]
[dependency-groups]
dev = [
"isort>=6.0.1",
"ruff>=0.14.2",
]
[[tool.uv.index]]
url = "http://mirrors.aliyun.com/pypi/simple"
default = true

48
router/__init__.py Normal file
View File

@@ -0,0 +1,48 @@
import importlib
import pkgutil
from pathlib import Path
from fastapi import APIRouter
from uvicorn.server import logger
# 定义一个 APIRouter 实例
router = APIRouter()
# 初始化 __all__ 列表,用于声明模块公开的对象
__all__ = []
# 获取当前文件所在目录
current_dir = Path(__file__).parent
# 定义可能的路由变量名列表
ROUTER_VARIABLE_NAMES = ["router", "routes", "api_router"]
# 遍历当前目录下的所有 .py 文件
for module_info in pkgutil.iter_modules([str(current_dir)]):
if module_info.name != Path(__file__).stem: # 排除当前文件
module = importlib.import_module(f".{module_info.name}", package=__package__)
found_router = False
# 遍历可能的路由变量名
for var_name in ROUTER_VARIABLE_NAMES:
if hasattr(module, var_name):
module_router: APIRouter = getattr(module, var_name)
api_number = len(module_router.routes)
logger.info(
f"[导入路由] 已导入模块: {module_info.name}.{var_name} 共计{api_number}个接口 ✅"
)
# 自动创建别名,使用模块名和变量名组合作为别名
alias_name = f"{module_info.name}_{var_name}"
globals()[alias_name] = module_router
# 包含模块的 router 到主 router 中
router.include_router(
router=module_router,
tags=[f"{alias_name} api"],
)
# 将别名添加到 __all__ 列表中
__all__.append(alias_name)
found_router = True
if not found_router:
logger.error(
f"[导入路由] 未导入模块:{module_info.name},未找到以下任一变量: {', '.join(ROUTER_VARIABLE_NAMES)} "
)

9
router/test.py Normal file
View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
async def hello():
return {
"hello" : "world"
}

93
utils/start.py Normal file
View File

@@ -0,0 +1,93 @@
import re
import pinyin
import pyfiglet
import toml
from fastapi import FastAPI
from pydantic.types import Callable
def extract_brand_name(input_str):
"""
提取品牌名称,规则如下:
- 中文:返回拼音首字母(大写)
- 英文:根据单词数量决定,少量单词返回大驼峰,大量单词返回纯大写
- 支持多种英文命名法(驼峰、中划线、下划线等)的解析
参数:
input_str: 输入的字符串(中英文均可)
返回:
处理后的品牌名称字符串
"""
# 判断是否为中文(包含中文字符)
if re.search(r"[\u4e00-\u9fff]", input_str):
# 提取中文并转换为拼音首字母
chinese_chars = re.findall(r"[\u4e00-\u9fff]", input_str)
pinyin_initials = "".join(
[
pinyin.get(c, format="strip", delimiter="")[0].upper()
for c in chinese_chars
]
)
return pinyin_initials
# 处理英文情况:拆分不同命名法的单词
# 处理驼峰命名(大小写转换处拆分)
s = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", input_str)
# 处理中划线、下划线和其他非字母字符,统一替换为空格
s = re.sub(r"[-_^&%$#@!~`]", " ", s)
# 提取所有单词(忽略非字母字符)
words = re.findall(r"[A-Za-z]+", s)
# 统一转为小写后再处理(避免大小写影响)
words = [word.lower() for word in words if word]
if not words:
return "" # 空输入处理
# 定义少量单词与大量单词的阈值这里使用3个作为分界
threshold = 3
if len(words) <= threshold:
# 少量单词:返回大驼峰格式
return "".join([word.capitalize() for word in words])
else:
# 大量单词:返回纯大写字母(取每个单词首字母)
return "".join([word[0].upper() for word in words])
# Load the TOML file
config = toml.load("pyproject.toml")
# Access specific values
project_name = config.get("project", {}).get("name", None)
project_description = config.get("project", {}).get("description", None)
project_version = config.get("project", {}).get("version", None)
project_authors = config.get("project", {}).get("authors", [])
def start(startup: Callable | None = None) -> FastAPI:
# from pyfiglet import FigletFont
# fonts = FigletFont().getFonts()
# for font in fonts:
# try:
# result = pyfiglet.figlet_format(project_name, font="red_phoenix")
# print(f"Font: {font}")
# print(result)
# print("-" * 50)
# except Exception as e:
# logger.warning(f"Failed to render font {font}: {e}")
#
if not startup:
raise ValueError("startup function is required")
print("-" * 50)
print(pyfiglet.figlet_format(project_name, font="red_phoenix"))
print(f"Project Description: {project_description}")
print(f"Version: {project_version}")
print("Author: ", end="")
[
print(f"{author.get('name', '')} {author.get('email', '')}", end="")
for author in project_authors
]
print()
print("-" * 50)
return startup()

2323
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff