diff --git a/CLAUDE.MD b/CLAUDE.MD index 05842c7..bbb650b 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -5,8 +5,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview VodHub is a pnpm monorepo with two applications: -- **Backend** (`apps/backend`): Hono-based video aggregation API that normalizes multiple video source providers into a unified REST API (categories, search, details, playback). Node.js >= 24, ESM. -- **Frontend** (`apps/frontend`): Vite + React 19 + React Router video player application with Ant Design 6, Zustand state management, and multi-theme support. +- **Backend** (`apps/backend`): Hono‑based video aggregation API that normalizes multiple video source providers into a unified REST API (categories, search, details, playback). Node.js ≥ 24, ESM. +- **Frontend** (`apps/frontend`): Vite + React 19 + React Router video player application with Ant Design 6, Zustand state management, and multi‑theme support. + +A shared package (`packages/shared`) provides core TypeScript types used by both apps. ## Common Development Commands @@ -17,24 +19,31 @@ pnpm dev # Start both backend and frontend dev servers pnpm dev:backend # Backend only (tsx watch) pnpm dev:frontend # Frontend only (vite dev) pnpm lint # Lint all apps -pnpm lint:fix # Lint with auto-fix +pnpm lint:fix # Lint with auto‑fix pnpm typecheck # Type check all apps pnpm format # Prettier write pnpm format:check # Prettier check pnpm commit # Interactive conventional commit +pnpm build # Build all apps (backend + frontend) ``` -### Backend-Specific Commands +### Backend‑Specific Commands ```bash pnpm --filter @vodhub/backend start # Start backend without watch pnpm --filter @vodhub/backend build # Build backend (generates routes first) pnpm --filter @vodhub/backend gen:routes # Generate route registry +pnpm --filter @vodhub/backend lint # Lint backend only +pnpm --filter @vodhub/backend lint:fix # Lint backend with auto‑fix +pnpm --filter @vodhub/backend typecheck # Type check backend only ``` -### Frontend-Specific Commands +### Frontend‑Specific Commands ```bash pnpm --filter @vodhub/frontend build # Production build pnpm --filter @vodhub/frontend preview # Preview production build +pnpm --filter @vodhub/frontend lint # Lint frontend only +pnpm --filter @vodhub/frontend lint:fix # Lint frontend with auto‑fix +pnpm --filter @vodhub/frontend typecheck # Type check frontend only ``` ## Backend Architecture @@ -42,65 +51,111 @@ pnpm --filter @vodhub/frontend preview # Preview production build ### Route System - **URL Pattern**: `GET/POST /api/vodhub//` - **Actions**: `home`, `homeVod`, `category`, `detail`, `play`, `search` -- **Auto-discovery**: Routes are automatically registered via `directory-import` in `apps/backend/src/routes/registry.ts` -- **Production optimization**: Routes are pre-generated to `registry.gen.ts` for faster startup +- **Auto‑discovery**: Routes are automatically registered via `directory‑import` in `apps/backend/src/routes/registry.ts` +- **Production optimization**: Routes are pre‑generated to `registry.gen.ts` for faster startup ### Provider Types 1. **CMS Providers** (Recommended): Only need `namespace.ts` and `index.ts` using `createCMSRoutes()` factory 2. **Custom Providers**: Individual route files (`home.ts`, `category.ts`, etc.) with custom logic +### CMS Management & Proxy +- **Proxy API**: `ALL /api/vodhub/proxy` – forwards requests to user‑defined CMS URLs using `x-proxy-target` and `x-proxy-action` headers to translate actions to the target CMS's API format +- **Frontend store**: CMS configurations are managed locally via `useCmsStore` (Zustand + localStorage) for immediate UI updates; backend API synchronization may be added in the future. + ### Middleware Order -1. `cors()` - CORS handling -2. `trimTrailingSlash()` - Trailing slash normalization -3. `compress()` - Response compression -4. `jsonReturn` - JSON response serialization -5. `cache` - Redis/memory caching with deduplication +1. `cors()` – CORS handling +2. `trimTrailingSlash()` – trailing slash normalization +3. `compress()` – response compression +4. `jsonReturn` – JSON response serialization +5. `cache` – Redis/memory caching with deduplication ### Caching System -- Two-level cache: memory LRU + optional Redis via Keyv -- Deduplication: Prevents concurrent identical requests -- Cache key format: `vod-hub:redis-cache:${path}${bodyHash}` -- Error responses: Set `Cache-Control: no-cache` to prevent caching failures +- Two‑level cache: memory LRU + optional Redis via Keyv +- Deduplication: prevents concurrent identical requests +- Cache key format: `vod‑hub:redis‑cache:${path}${bodyHash}` +- Error responses: set `Cache‑Control: 'no‑cache'` to prevent caching failures ### Error Handling Pattern All handlers must follow this structure: ```typescript try { - logger.info(`${ACTION_MESSAGE.INFO} - ${namespace.name}`); + logger.info(`${ACTION_MESSAGE.INFO} ‑ ${namespace.name}`); const res = await someRequest(); if (res.code === 1) { // CMS convention - return { code: SUCCESS_CODE, message: ACTION_MESSAGE.SUCCESS, data: [...] }; + return { code: SUCCESS_CODE, message: ACTION_MESSAGE.SUCCESS, data: […] }; } - logger.error(`${ACTION_MESSAGE.ERROR} - ${namespace.name} - ${JSON.stringify(res)}`); + logger.error(`${ACTION_MESSAGE.ERROR} ‑ ${namespace.name} ‑ ${JSON.stringify(res)}`); return { code: ERROR_CODE, message: ACTION_MESSAGE.ERROR, data: [] }; } catch (error) { - ctx.res.headers.set('Cache-Control', 'no-cache'); - logger.error(`${ACTION_MESSAGE.ERROR} - ${namespace.name} - ${error}`); + ctx.res.headers.set('Cache‑Control', 'no‑cache'); + logger.error(`${ACTION_MESSAGE.ERROR} ‑ ${namespace.name} ‑ ${error}`); return { code: SYSTEM_ERROR_CODE, message: ACTION_MESSAGE.ERROR, data: [] }; } ``` **Status Codes**: -- `SUCCESS_CODE = 0` - Successful response -- `ERROR_CODE = -1` - Business logic error (upstream failure) -- `SYSTEM_ERROR_CODE = -2` - Exception/catch error +- `SUCCESS_CODE = 0` – successful response +- `ERROR_CODE = -1` – business logic error (upstream failure) +- `SYSTEM_ERROR_CODE = -2` – exception/catch error + +### Import Conventions +- **Groups**: Built‑ins → external packages → internal modules (`@/`), separated by blank lines +- **Alphabetization**: Imports within each group should be alphabetized (case‑insensitive) +- **Type imports**: Use `import type` for type‑only imports +- **Node built‑ins**: Use the `node:` prefix (e.g., `import { join } from 'node:path'`) + +### Route Object Shape +Each custom route file exports a `route` object with the following properties: +- `path`: string or string[] – URL path segment(s) +- `name`: string – route identifier (matches filename) +- `example`: string – example URL path +- `description`: string – what the route does +- `handler`: (ctx: Context) => Promise | T +- `method?`: 'GET' | 'POST' – defaults to GET + +### Handler Naming +- The handler function must be named `handler` (constant) in each route file +- Use typed `RouteItem` generic for route definitions ## Frontend Architecture ### Tech Stack -- **Framework**: Vite + React 19 + React Router + TypeScript -- **UI**: Ant Design 6 with SCSS Modules +- **Framework**: Vite + React 19 + React Router + TypeScript +- **UI**: Ant Design 6 with SCSS Modules - **State Management**: Zustand stores with `persist` middleware - **Video Player**: xgplayer + HLS.js -- **Styling**: CSS variables for multi-theme support +- **Styling**: CSS variables for multi‑theme support ### Theme System -Three built-in themes defined in `lib/themes/index.ts`: -- **midnight**: Dark with red accent -- **aurora**: Light with cyan accent -- **cyber**: Dark with purple accent +Three built‑in themes defined in `lib/themes/index.ts`: +- **midnight**: dark with red accent +- **aurora**: light with cyan accent +- **cyber**: dark with purple accent -**Key Rule**: All colors must use CSS variables (`var(--color-bg-container)`) - never hardcode hex/rgba values. +**Key Rule**: All colors must use CSS variables (`var(--color‑bg‑container)`) – never hardcode hex/rgba values. + +### CSS Variables Reference +| Category | Variable | Description | +|---|---|---| +| Brand | `--color-primary` | Primary color | +| Brand | `--color-primary-light` | Light primary | +| Background | `--color-bg` | Page background | +| Background | `--color-bg-container` | Container background | +| Background | `--color-bg-elevated` | Elevated background | +| Background | `--color-bg-container-alpha` | Semi‑transparent container | +| Background | `--color-bg-elevated-alpha` | Semi‑transparent elevated | +| Background | `--color-bg-elevated-hover` | Hover state background | +| Text | `--color-text` | Primary text | +| Text | `--color-text-secondary` | Secondary text | +| Text | `--color-text-tertiary` | Tertiary text | +| Border | `--color-border` | Border | +| Border | `--color-border-secondary` | Secondary border | +| State | `--color-primary-alpha-low` | Selected state | +| State | `--color-primary-alpha-medium` | Border accent | +| State | `--color-primary-alpha-hover` | Hover state | +| State | `--color-primary-shadow` | Shadow | +| Overlay | `--color-overlay` | Overlay background | +| Overlay | `--color-overlay-border` | Overlay border | ### State Management - Stores in `lib/store/` with naming `use{Name}Store.ts` @@ -108,23 +163,44 @@ Three built-in themes defined in `lib/themes/index.ts`: - Theme state updates both CSS variables and Ant Design theme config ### Component Structure -- Components are client-side by default (no `'use client'` directive needed) -- SCSS Modules with `vod-next-` class prefix (historical naming convention) +- Components are client‑side by default (no `'use client'` directive needed) +- SCSS Modules with `vod‑next‑` class prefix (historical naming convention) - Props defined with `interface` and destructured in params - Components use `export default` +### Error Handling +- **HTTP errors**: Rejected via Axios interceptor (`Promise.reject(error)`) +- **API calls**: Wrap in try/catch, re‑throw as `Promise.reject` +- **UI errors**: Display with Ant Design `message` component + +### Performance Guidelines +- Use `React.memo` to avoid unnecessary re‑renders +- Use `useMemo` and `useCallback` for expensive computations +- Use `React.lazy()` with `Suspense` for heavy components (e.g., video player) +- Use `Suspense` for async loading + +### Design System +- See `DESIGN.md` for Vercel‑inspired visual guidelines (colors, typography, shadows, component styling). + +### CMS Management UI +- Component: `components/cms‑management/index.tsx` +- Store: `lib/store/useCmsStore.ts` (localStorage only) +- Allows adding, editing, deleting, and testing custom CMS sources +- Changes are persisted locally in localStorage; backend API synchronization may be added in the future + ## Shared Types + Core types defined in `packages/shared/src/types/index.ts`: -- `Namespace` - Provider metadata -- `HomeData`, `HomeVodData`, `CategoryVodData` - Data structures -- `DetailData`, `PlayData`, `SearchData` - Response types -- `ApiResponse` - Standard API response wrapper +- `Namespace` – provider metadata +- `HomeData`, `HomeVodData`, `CategoryVodData` – data structures +- `DetailData`, `PlayData`, `SearchData` – response types +- `ApiResponse` – standard API response wrapper ## Deployment ### Docker Compose -- **Development**: `docker-compose up -d` (uses `docker-compose.yml`) -- **Production**: `docker-compose -f docker-compose.prod.yml up -d` (includes Redis, log persistence) +- **Development**: `docker‑compose up ‑d` (uses `docker‑compose.yml`) +- **Production**: `docker‑compose ‑f docker‑compose.prod.yml up ‑d` (includes Redis, log persistence) ### Production Environment - **Services**: frontend, backend, redis @@ -135,10 +211,10 @@ Core types defined in `packages/shared/src/types/index.ts`: ## Development Standards ### Code Style -- **Backend**: Named exports only, `any` disallowed, `no-explicit-any: error` +- **Backend**: Named exports only, `any` disallowed (`no‑explicit‑any: error`) - **Frontend**: `any` allowed, `export default` for components, named exports for utilities - **Imports**: Builtins → externals → internals, alphabetized, blank line between groups -- **Formatting**: Single quotes, no trailing commas, 4-space indent, 200 char width, semicolons +- **Formatting**: Single quotes, no trailing commas, 4‑space indent, 200‑char width, semicolons ### Naming Conventions | Item | Convention | Example | @@ -149,15 +225,21 @@ Core types defined in `packages/shared/src/types/index.ts`: | Frontend components | `PascalCase` | `VodList` | | Frontend hooks | `use` prefix | `useIsMobile` | | Frontend stores | `use` + `Store` suffix | `useSettingStore` | +| Backend handler functions | `handler` | `const handler = async (ctx) => { … }` | ### Git Workflow -- Husky + lint-staged for pre-commit hooks +- Husky + lint‑staged for pre‑commit hooks - Commitlint enforces conventional commits - `pnpm commit` for interactive commit messages +### Code Quality Assurance +- **After generating code**: Always run `pnpm lint:fix` and `pnpm format` to ensure consistent style +- **Before committing**: Pre‑commit hooks automatically run linting and formatting +- **Type safety**: Run `pnpm typecheck` to verify TypeScript types across the monorepo + ## Important Notes - No test framework is configured in this project - Backend uses ESM (`"type": "module"`) - CMS handlers check `res.code === 1` for upstream success - All error responses return `data: []` (empty array) -- Route handlers must never throw - always return structured response \ No newline at end of file +- Route handlers must never throw – always return structured response diff --git a/README.md b/README.md index 48c7c6f..d5a8986 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,17 @@ VodHub 是一个完整的视频聚合解决方案,包含两个核心部分: -- **后端**: 电影、电视剧、动漫等视频接口聚合服务,转换多个视频源为标准统一输出、支持CMS视频源接入 -- **前端**: 视频源聚合播放器,提供分类、搜索、详情、播放等功能 - -## ✨ 功能特性 - ### 后端服务 - 📦 开箱即用的接口服务 -- ⚙️ 转换多个视频源为标准输出,支持分类、搜索、详情、播放等功能 +- ⚙️ 内置多个CMS标准视频源 - 🛡 使用 TypeScript 开发,保持输出接口的一致性 ### 前端应用 - 📦 支持 VodHub 标准视频源 -- ⚙️ 提供视频分类、搜索、详情、播放等页面 +- 🖥 提供视频分类、搜索、详情、播放等页面 - 🐳 支持 PC、手机端页面自适应 +- 🎨 内置多套主题 一键切换 +- ⚙️ 支持自定义CMS视频源 ## 📸 界面展示 @@ -33,11 +30,11 @@ VodHub 是一个完整的视频聚合解决方案,包含两个核心部分: #### 播放页 ![播放页](/apps/frontend/docs/images/play.png) -#### 暗黑模式 -![暗黑模式](/apps/frontend/docs/images/dark.png) +#### CMS配置、主题选择 +![设置页](/apps/frontend/docs/images/theme1.png) -#### 手机适配 -![手机适配](/apps/frontend/docs/images/mobile.png) +#### Claude风格主题 +![claude风格](/apps/frontend/docs/images/theme.png) ## 🖥 开发环境 @@ -77,10 +74,10 @@ $ pnpm dev:frontend ### 配置前端 VodHub 接口地址 #### 方式一 -本地启动 VodHub 服务,配置为 `/` 即可 +本地启动前后端,配置为 `/` 即可 #### 方式二 -部署后的 VodHub 地址,配置为 `http://abc.com` 即可 +只启动前端,使用第三方提供的VodHub服务,配置为 `http://abc.com` 即可 ### 后端 API 使用示例 diff --git a/apps/backend/src/middleware/jsonReturn.ts b/apps/backend/src/middleware/jsonReturn.ts index c070bee..004d500 100644 --- a/apps/backend/src/middleware/jsonReturn.ts +++ b/apps/backend/src/middleware/jsonReturn.ts @@ -2,6 +2,12 @@ import type { MiddlewareHandler } from 'hono'; const middleware: MiddlewareHandler = async (ctx, next) => { await next(); + + // 如果响应体已经被设置(例如通过 c.json()),则直接返回 + if (ctx.res.body) { + return; + } + const data = ctx.get('data'); ctx.header('Content-Type', 'application/json'); return ctx.body(JSON.stringify(data)); diff --git a/apps/backend/src/routes/proxy.ts b/apps/backend/src/routes/proxy.ts index a84bd2d..d70dbaf 100644 --- a/apps/backend/src/routes/proxy.ts +++ b/apps/backend/src/routes/proxy.ts @@ -1,69 +1,74 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; -import queryString from 'query-string'; -import { omitHeaders } from '@/utils/headers'; +import type { Namespace } from '@/types'; +import { handler as categoryHandler } from '@/utils/cms/category'; +import { handler as detailHandler } from '@/utils/cms/detail'; +import { handler as homeHandler } from '@/utils/cms/home'; +import { handler as homeVodHandler } from '@/utils/cms/homeVod'; +import { handler as playHandler } from '@/utils/cms/play'; +import { handler as searchHandler } from '@/utils/cms/search'; import logger from '@/utils/logger'; -const ALLOWED_PROXY_TARGETS = (process.env.ALLOWED_PROXY_TARGETS || '').split(',').filter(Boolean); - const app = new Hono(); -function isAllowedTarget(target: string): boolean { - if (ALLOWED_PROXY_TARGETS.length === 0) { - return false; - } - try { - const url = new URL(target); - return ALLOWED_PROXY_TARGETS.some((allowed) => url.hostname.endsWith(allowed)); - } catch { - return false; - } -} +const createNamespace = (target: string): Namespace => ({ + name: 'custom', + url: target +}); -async function handleProxyRequest(ctx: Context, method = 'GET') { - const query = ctx.req.query(); - const strParams = queryString.stringify(query); +app.all('/', async (ctx: Context): Promise => { const target = ctx.req.header('x-proxy-target') || ''; - const path = ctx.req.header('x-proxy-path'); + const action = ctx.req.header('x-proxy-action') || ''; + + logger.info(`Proxy request: target=${target}, action=${action}`); if (!target) { - return ctx.json({ error: 'header x-proxy-target is required' }, 400); + return ctx.json({ code: -1, message: 'header x-proxy-target is required', data: [] }); } - if (!path) { - return ctx.json({ error: 'header x-proxy-path is required' }, 400); - } - if (!isAllowedTarget(target)) { - return ctx.json({ error: 'target not allowed' }, 403 as const); + + if (!action) { + return ctx.json({ code: -1, message: 'header x-proxy-action is required', data: [] }); } - const headers = ctx.req.header(); - const url = `${target}${path}?${strParams}`; + const namespace = createNamespace(target); + const method = ctx.req.method; - try { - const response = await fetch(url, { - method, - headers: { - ...omitHeaders(headers, ['content-length', 'x-proxy-path', 'x-proxy-target']), - 'Content-Type': 'application/json', - Referer: target - }, - ...(method === 'POST' ? { body: JSON.stringify(await ctx.req.json()) } : {}) - }); + let result: Response; - if (!response.ok) { - return ctx.json({ error: 'Failed to proxy request' }, response.status as 400 | 500); + if (method === 'GET') { + switch (action) { + case 'list': + result = ctx.json(await homeHandler(ctx, namespace)); + break; + case 'detail': + result = ctx.json(await homeVodHandler(ctx, namespace)); + break; + default: + result = ctx.json({ code: -1, message: 'Unknown GET action: ' + action, data: [] }); + break; + } + } else { + switch (action) { + case 'detail': + result = ctx.json(await detailHandler(ctx, namespace)); + break; + case 'play': + result = ctx.json(await playHandler(ctx, namespace)); + break; + case 'search': + result = ctx.json(await searchHandler(ctx, namespace)); + break; + case 'category': + result = ctx.json(await categoryHandler(ctx, namespace)); + break; + default: + result = ctx.json({ code: -1, message: 'Unknown POST action: ' + action, data: [] }); + break; } - - const { status, statusText, headers: resHeaders } = response; - return new Response(response.body, { status, statusText, headers: resHeaders }); - } catch (error) { - logger.error(`Proxy request failed: ${error instanceof Error ? error.message : String(error)}`); - return ctx.json({ error: 'Proxy request failed' }, 500); } -} -app.get('/', async (ctx) => handleProxyRequest(ctx, 'GET')); -app.post('/', async (ctx) => handleProxyRequest(ctx, 'POST')); + return result; +}); export default app; diff --git a/apps/frontend/docs/images/dark.png b/apps/frontend/docs/images/dark.png deleted file mode 100644 index d8e1a84..0000000 Binary files a/apps/frontend/docs/images/dark.png and /dev/null differ diff --git a/apps/frontend/docs/images/home.png b/apps/frontend/docs/images/home.png index 5f5b65a..b7d3ebf 100644 Binary files a/apps/frontend/docs/images/home.png and b/apps/frontend/docs/images/home.png differ diff --git a/apps/frontend/docs/images/mobile.png b/apps/frontend/docs/images/mobile.png deleted file mode 100644 index aac1d86..0000000 Binary files a/apps/frontend/docs/images/mobile.png and /dev/null differ diff --git a/apps/frontend/docs/images/play.png b/apps/frontend/docs/images/play.png index 3e2a19c..0e0f2e0 100644 Binary files a/apps/frontend/docs/images/play.png and b/apps/frontend/docs/images/play.png differ diff --git a/apps/frontend/docs/images/search.png b/apps/frontend/docs/images/search.png index 2243ff5..4f3b877 100644 Binary files a/apps/frontend/docs/images/search.png and b/apps/frontend/docs/images/search.png differ diff --git a/apps/frontend/docs/images/theme.png b/apps/frontend/docs/images/theme.png new file mode 100644 index 0000000..ab7ea89 Binary files /dev/null and b/apps/frontend/docs/images/theme.png differ diff --git a/apps/frontend/docs/images/theme1.png b/apps/frontend/docs/images/theme1.png new file mode 100644 index 0000000..0f612f9 Binary files /dev/null and b/apps/frontend/docs/images/theme1.png differ diff --git a/apps/frontend/src/components/BasicLayout.module.scss b/apps/frontend/src/components/BasicLayout.module.scss index f24a912..78d16e4 100644 --- a/apps/frontend/src/components/BasicLayout.module.scss +++ b/apps/frontend/src/components/BasicLayout.module.scss @@ -1,5 +1,6 @@ .vod-layout { - font-family: 'Righteous', 'Poppins', 'Microsoft YaHei', sans-serif; + font-family: 'Geist', 'Inter', 'Microsoft YaHei', sans-serif; + font-feature-settings: 'liga' 1; height: 100%; background: var(--color-bg); @@ -23,8 +24,10 @@ height: 48px; background: transparent; color: var(--color-text-tertiary); - font-size: 13px; + font-size: 12px; + font-weight: 400; gap: 8px; + border-top: 1px solid var(--color-border); &-divider { opacity: 0.3; diff --git a/apps/frontend/src/components/InitProvider.tsx b/apps/frontend/src/components/InitProvider.tsx index 07d8c38..5dbae4d 100644 --- a/apps/frontend/src/components/InitProvider.tsx +++ b/apps/frontend/src/components/InitProvider.tsx @@ -20,9 +20,7 @@ export default function InitProvider({ children }: { children: React.ReactNode } useEffect(() => { if (!isInitialized && location.pathname !== '/setting') { - getVodTypes({ - force: false - }); + getVodTypes(); } }, [getVodTypes, isInitialized, location.pathname]); diff --git a/apps/frontend/src/components/SiteHeader.tsx b/apps/frontend/src/components/SiteHeader.tsx index afc173b..7a8be35 100644 --- a/apps/frontend/src/components/SiteHeader.tsx +++ b/apps/frontend/src/components/SiteHeader.tsx @@ -14,9 +14,7 @@ const { Header } = Layout; const SiteHeader = () => { const navigate = useNavigate(); - const { current_site, setting, updateSetting } = useSettingStore(); - - const { site_name } = useSettingStore(); + const { vod_hub_api, site_name, current_site, updateSetting } = useSettingStore(); const { sites } = useVodSitesStore(); @@ -42,7 +40,7 @@ const SiteHeader = () => { options={sites} value={current_site} onChange={(value) => { - updateSetting({ ...setting, current_site: value }); + updateSetting({ vod_hub_api, site_name, current_site: value }); navigate('/home'); }} /> diff --git a/apps/frontend/src/components/ThemeProvider.tsx b/apps/frontend/src/components/ThemeProvider.tsx index d253276..386bec0 100644 --- a/apps/frontend/src/components/ThemeProvider.tsx +++ b/apps/frontend/src/components/ThemeProvider.tsx @@ -1,5 +1,5 @@ import { App, ConfigProvider, theme as antdTheme } from 'antd'; -import zhCN from 'antd/lib/locale/zh_CN'; +import zhCN from 'antd/es/locale/zh_CN'; import { useEffect } from 'react'; import { useThemeStore } from '@/lib/store/useThemeStore'; diff --git a/apps/frontend/src/components/cms-management/index.tsx b/apps/frontend/src/components/cms-management/index.tsx new file mode 100644 index 0000000..bfbe68b --- /dev/null +++ b/apps/frontend/src/components/cms-management/index.tsx @@ -0,0 +1,265 @@ +import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { CustomCms, CreateCustomCmsInput } from '@vodhub/shared/types/custom-cms'; +import { Button, Table, Tag, Space, Modal, Form, Input, Switch, message, Popconfirm, Typography, Descriptions } from 'antd'; +import { useEffect, useState } from 'react'; + +import useCmsStore from '@/lib/store/useCmsStore'; +import useSettingStore from '@/lib/store/useSettingStore'; +import { useVodSitesStore } from '@/lib/store/useVodSitesStore'; + +const { Text } = Typography; + +const CmsManagement = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isViewModalOpen, setIsViewModalOpen] = useState(false); + const [editingCms, setEditingCms] = useState(null); + const [viewingCms, setViewingCms] = useState(null); + const [form] = Form.useForm(); + + const { cmsList, isLoading, error, fetchCmsList, addCms, updateCms, deleteCms, clearError } = useCmsStore(); + const { vod_hub_api, site_name, current_site, updateSetting } = useSettingStore(); + const { getVodTypes } = useVodSitesStore(); + + useEffect(() => { + fetchCmsList(); + }, []); + + useEffect(() => { + if (error) { + message.error(error); + clearError(); + } + }, [error]); + + const handleAdd = () => { + setEditingCms(null); + form.resetFields(); + setIsModalOpen(true); + }; + + const handleEdit = (cms: CustomCms) => { + setEditingCms(cms); + form.setFieldsValue({ + name: cms.name, + url: cms.url, + description: cms.description, + enabled: cms.enabled + }); + setIsModalOpen(true); + }; + + const handleView = (cms: CustomCms) => { + setViewingCms(cms); + setIsViewModalOpen(true); + }; + + const handleDelete = async (id: string) => { + try { + await deleteCms(id); + message.success('删除成功'); + + // 如果当前选中的站点是被删除的CMS,则清除选中 + if (current_site === `custom_${id}`) { + updateSetting({ + vod_hub_api: vod_hub_api || '', + site_name: site_name || '', + current_site: '' + }); + } + + // 刷新站点列表 + await getVodTypes(); + } catch { + // 错误已在store中处理 + } + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + const input: CreateCustomCmsInput = { + name: values.name, + url: values.url, + description: values.description, + enabled: values.enabled ?? true + }; + + if (editingCms) { + await updateCms({ ...input, id: editingCms.id }); + message.success('更新成功'); + + // 如果当前选中的站点是被禁用的CMS,则清除选中 + if (!input.enabled && current_site === `custom_${editingCms.id}`) { + updateSetting({ + vod_hub_api: vod_hub_api || '', + site_name: site_name || '', + current_site: '' + }); + } + } else { + await addCms(input); + message.success('添加成功'); + } + + // 刷新站点列表 + await getVodTypes(); + + setIsModalOpen(false); + form.resetFields(); + } catch (error) { + console.error('表单验证失败:', error); + } + }; + + const handleModalCancel = () => { + setIsModalOpen(false); + form.resetFields(); + }; + + const handleViewModalCancel = () => { + setIsViewModalOpen(false); + setViewingCms(null); + }; + + const columns = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + render: (text: string, record: CustomCms) => ( + + {text} + {!record.enabled && 已禁用} + + ) + }, + { + title: '地址', + dataIndex: 'url', + key: 'url', + render: (text: string) => ( + + {text} + + ) + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + render: (text?: string) => text || '-' + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + render: (enabled: boolean) => {enabled ? '启用' : '禁用'} + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date: string) => new Date(date).toLocaleString() + }, + { + title: '操作', + key: 'action', + render: (_: any, record: CustomCms) => ( + + + + + + + + + {/* 添加/编辑模态框 */} + +
+ + + + + + + + + + + + + + + + +
+ + {/* 查看模态框 */} + + 关闭 + + ]} + width={600} + > + {viewingCms && ( + + + {viewingCms.id} + + {viewingCms.name} + + {viewingCms.url} + + {viewingCms.description || '-'} + + {viewingCms.enabled ? '启用' : '禁用'} + + {new Date(viewingCms.createdAt).toLocaleString()} + {new Date(viewingCms.updatedAt).toLocaleString()} + + )} + + + ); +}; + +export default CmsManagement; diff --git a/apps/frontend/src/components/ui/ThemeSelector.tsx b/apps/frontend/src/components/ui/ThemeSelector.tsx index e44b917..4f4fe0e 100644 --- a/apps/frontend/src/components/ui/ThemeSelector.tsx +++ b/apps/frontend/src/components/ui/ThemeSelector.tsx @@ -12,104 +12,99 @@ export function ThemeSelector() { const { themeId, setThemeId } = useThemeStore(); return ( - - - 主题风格 - - - {themeList.map((theme) => { - const isSelected = themeId === theme.id; + + {themeList.map((theme) => { + const isSelected = themeId === theme.id; - return ( - setThemeId(theme.id as ThemeId)} - className={`${styles['theme-card']} ${isSelected ? styles['theme-card-selected'] : ''}`} - style={{ - width: 200, - cursor: 'pointer', - position: 'relative', - overflow: 'hidden' - }} - styles={{ - body: { - padding: 16 - } - }} - > - {isSelected && ( -
- -
- )} + return ( + setThemeId(theme.id as ThemeId)} + className={`${styles['theme-card']} ${isSelected ? styles['theme-card-selected'] : ''}`} + style={{ + width: 200, + cursor: 'pointer', + position: 'relative', + overflow: 'hidden' + }} + styles={{ + body: { + padding: 16 + } + }} + > + {isSelected && ( +
+ +
+ )} - -
-
-
-
-
-
-
-
-
+ +
+
+
+
+
+
+
+
+
- - - {theme.name} - - - {theme.description} - - + + + {theme.name} + + + {theme.description} + - - ); - })} - + + + ); + })} ); } diff --git a/apps/frontend/src/components/video/VodList/index.module.scss b/apps/frontend/src/components/video/VodList/index.module.scss index bcf19e7..be492eb 100644 --- a/apps/frontend/src/components/video/VodList/index.module.scss +++ b/apps/frontend/src/components/video/VodList/index.module.scss @@ -1,143 +1,141 @@ -.vod-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 20px; +.vod-list-container { + width: 100%; +} - &-item { - display: flex; - flex-direction: column; - cursor: pointer; +.vod-list-item { + display: flex; + flex-direction: column; + cursor: pointer; +} - &-cover { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - height: 0; - padding-top: 140%; - background-position: 50% 50%; - background-size: cover; - overflow: hidden; - border-radius: 12px; - background-color: var(--color-bg-container); - border: 1px solid var(--color-border-secondary); +.vod-list-item-cover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + height: 0; + padding-top: 140%; + background-position: 50% 50%; + background-size: cover; + overflow: hidden; + border-radius: 12px; + background-color: var(--color-bg-container); + box-shadow: var(--shadow-border-light); +} - img { - transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } +.vod-list-item-cover img { + transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} - &:hover { - transform: translateY(-4px); - box-shadow: 0 12px 40px var(--color-primary-shadow); - border-color: var(--color-primary-alpha-medium); +.vod-list-item-cover:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-card); + border-color: transparent; +} - img { - transform: scale(1.08); - } +.vod-list-item-cover:hover img { + transform: scale(1.08); +} - .vod-list-item-cover-play { - opacity: 1; - } - } +.vod-list-item-cover:hover .vod-list-item-cover-play { + opacity: 1; +} - &-play { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 48px; - height: 48px; - background: var(--color-primary); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s; - backdrop-filter: blur(8px); - box-shadow: 0 4px 20px var(--color-primary-shadow); +.vod-list-item-cover-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + background: var(--color-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s; + backdrop-filter: blur(8px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} - svg { - margin-left: 2px; - } - } +.vod-list-item-cover-play svg { + margin-left: 2px; +} - &-new { - position: absolute; - top: 8px; - right: 8px; - color: #fff; - border-radius: 6px; - padding: 3px 8px; - background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); - z-index: 1; - font-size: 11px; - font-weight: 600; - box-shadow: 0 2px 8px var(--color-primary-shadow); - } +.vod-list-item-cover-new { + position: absolute; + top: 8px; + right: 8px; + color: #fff; + border-radius: 6px; + padding: 3px 8px; + background: var(--color-primary); + z-index: 1; + font-size: 11px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} - &-douban { - position: absolute; - top: 8px; - left: 8px; - z-index: 1; - background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); - padding: 3px 8px; - pointer-events: none; - color: #fff; - font-weight: 600; - font-size: 11px; - border-radius: 6px; - box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); - } +.vod-list-item-cover-douban { + position: absolute; + top: 8px; + left: 8px; + z-index: 1; + background: #22c55e; + padding: 3px 8px; + pointer-events: none; + color: #fff; + font-weight: 600; + font-size: 11px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); +} - &-remarks { - position: absolute; - right: 8px; - bottom: 8px; - z-index: 1; - pointer-events: none; - color: #fff; - font-weight: 600; - background: var(--color-overlay); - backdrop-filter: blur(8px); - border-radius: 6px; - padding: 3px 8px; - font-size: 11px; - border: 1px solid var(--color-overlay-border); - } +.vod-list-item-cover-remarks { + position: absolute; + right: 8px; + bottom: 8px; + z-index: 1; + pointer-events: none; + color: #fff; + font-weight: 600; + background: var(--color-overlay); + backdrop-filter: blur(8px); + border-radius: 6px; + padding: 3px 8px; + font-size: 11px; + border: 1px solid var(--color-overlay-border); +} - &-pic { - position: absolute !important; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - } - } +.vod-list-item-cover-pic { + position: absolute !important; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; +} - &-info { - margin-top: 12px; - width: 100%; +.vod-list-item-info { + margin-top: 12px; + width: 100%; +} - &-title { - text-align: left; - font-size: 14px; - font-weight: 500; - display: block; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - cursor: pointer; - transition: color 0.2s; - color: var(--color-text-secondary); - line-height: 1.4; +.vod-list-item-info-title { + text-align: left; + font-size: 14px; + font-weight: 500; + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + cursor: pointer; + transition: color 0.2s; + color: var(--color-text-secondary); + line-height: 1.4; +} - &:hover { - color: var(--color-text); - } - } - } - } +.vod-list-item-info-title:hover { + color: var(--color-text); } diff --git a/apps/frontend/src/components/video/VodList/index.tsx b/apps/frontend/src/components/video/VodList/index.tsx index a398ea4..6139332 100644 --- a/apps/frontend/src/components/video/VodList/index.tsx +++ b/apps/frontend/src/components/video/VodList/index.tsx @@ -1,4 +1,4 @@ -import { Image } from 'antd'; +import { Col, Image, Row } from 'antd'; import styles from './index.module.scss'; @@ -17,43 +17,46 @@ const VodList: React.FC = (props) => { } return ( -
- {dataSource.map((vod, index) => { - return ( -
{ - onItemClick?.(vod); - }} - > -
- {vod.vod_remarks &&
{vod.vod_remarks}
} -
- - - -
- + + {dataSource.map((vod, index) => { + return ( +
+
{ + onItemClick?.(vod); }} - fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEX///8AAABVwtN+AAAAAXRSTlMAQObYZgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAyRJREFUeNrtwQEBAAAAgiD/r25IQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfBp4DwABPq7bWwAAAABJRU5ErkJggg==" - > -
-
-
{vod.vod_name}
-
- - ); - })} + > +
+ {vod.vod_remarks &&
{vod.vod_remarks}
} +
+ + + +
+ +
+
+
{vod.vod_name}
+
+ + + ); + })} + ); }; diff --git a/apps/frontend/src/components/video/VodPalyer/index.module.scss b/apps/frontend/src/components/video/VodPalyer/index.module.scss index b0bbf40..7cbc53a 100644 --- a/apps/frontend/src/components/video/VodPalyer/index.module.scss +++ b/apps/frontend/src/components/video/VodPalyer/index.module.scss @@ -3,6 +3,6 @@ aspect-ratio: 16 / 9; margin-bottom: 16px; border-radius: 16px; - box-shadow: 0 4px 24px rgba(225, 29, 72, 0.1); - border: 2px solid var(--color-border); + box-shadow: var(--shadow-border); + border: none; } diff --git a/apps/frontend/src/components/video/VodSearch/index.tsx b/apps/frontend/src/components/video/VodSearch/index.tsx index a95df66..0277952 100644 --- a/apps/frontend/src/components/video/VodSearch/index.tsx +++ b/apps/frontend/src/components/video/VodSearch/index.tsx @@ -180,7 +180,7 @@ const VodSearch: React.FC = (props) => { style={{ position: 'fixed', inset: 0, - backgroundColor: '#1a1c1f', + backgroundColor: 'rgba(0, 0, 0, 0.8)', zIndex: 1000 }} > diff --git a/apps/frontend/src/components/video/VodTypes/index.module.scss b/apps/frontend/src/components/video/VodTypes/index.module.scss index 9e54e4f..3a9da04 100644 --- a/apps/frontend/src/components/video/VodTypes/index.module.scss +++ b/apps/frontend/src/components/video/VodTypes/index.module.scss @@ -1,8 +1,9 @@ .vod-types { background: var(--color-bg-container); backdrop-filter: blur(12px); - border-bottom: 1px solid var(--color-border-secondary); + border-bottom: 1px solid var(--color-border); position: relative; + padding: 0 24px; } .tabs-wrapper { @@ -18,19 +19,20 @@ width: 32px; height: 32px; background: var(--color-bg-elevated); - border: 1px solid var(--color-border-secondary); - border-radius: 8px; + border: none; + border-radius: 6px; color: var(--color-text-secondary); cursor: pointer; transition: all 0.2s ease; flex-shrink: 0; z-index: 2; margin: 0 8px; + box-shadow: var(--shadow-border); &:hover { - background: var(--color-border); + background: var(--color-bg-elevated-hover); color: var(--color-text); - border-color: var(--color-border); + box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.2); } &:active { @@ -38,7 +40,7 @@ } &:focus-visible { - outline: 2px solid var(--color-primary); + outline: 2px solid var(--focus-blue); outline-offset: 2px; } } @@ -70,7 +72,7 @@ padding: 10px 16px; background: transparent; border: none; - border-radius: 8px; + border-radius: 6px; color: var(--color-text-secondary); font-size: 14px; font-weight: 500; @@ -85,13 +87,14 @@ } &:focus-visible { - outline: 2px solid var(--color-primary); + outline: 2px solid var(--focus-blue); outline-offset: 2px; } &.active { color: var(--color-text); background: var(--color-primary-alpha-hover); + box-shadow: var(--shadow-border); .tab-label { font-weight: 600; @@ -111,7 +114,7 @@ transform: translateX(-50%); width: 20px; height: 3px; - background: linear-gradient(90deg, var(--color-primary), var(--color-primary-light)); + background: var(--color-primary); border-radius: 2px; animation: slideIn 0.2s ease; } diff --git a/apps/frontend/src/globals.scss b/apps/frontend/src/globals.scss index f32bcd7..ade15a6 100644 --- a/apps/frontend/src/globals.scss +++ b/apps/frontend/src/globals.scss @@ -1,25 +1,54 @@ -@import url('https://fonts.googleapis.com/css2?family=Righteous&family=Poppins:wght@300;400;500;600;700&display=swap'); +/* Geist字体来自Vercel CDN */ +@font-face { + font-family: 'Geist'; + src: url('https://assets.vercel.com/raw/upload/v1660068731/fonts/4/GeistVariable.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist Mono'; + src: url('https://assets.vercel.com/raw/upload/v1660068731/fonts/4/GeistMonoVariable.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} :root { - --color-primary: #e11d48; - --color-primary-light: #fb7185; - --color-bg: #000000; - --color-bg-container: #0f0f23; - --color-bg-elevated: #1e1b4b; - --color-text: #f8fafc; - --color-text-secondary: #94a3b8; - --color-text-tertiary: #64748b; - --color-border: #334155; - --color-border-secondary: rgba(51, 65, 85, 0.5); - --color-bg-container-alpha: rgba(15, 15, 35, 0.6); - --color-bg-elevated-alpha: rgba(30, 27, 75, 0.5); - --color-bg-elevated-hover: rgba(30, 27, 75, 0.8); - --color-primary-alpha-low: rgba(225, 29, 72, 0.1); - --color-primary-alpha-medium: rgba(225, 29, 72, 0.3); - --color-primary-alpha-hover: rgba(225, 29, 72, 0.12); - --color-primary-shadow: rgba(225, 29, 72, 0.4); - --color-overlay: rgba(0, 0, 0, 0.7); - --color-overlay-border: rgba(255, 255, 255, 0.1); + /* Vercel设计系统颜色 */ + --color-primary: #171717; + --color-primary-light: #4d4d4d; + --color-bg: #ffffff; + --color-bg-container: #ffffff; + --color-bg-elevated: #fafafa; + --color-text: #171717; + --color-text-secondary: #4d4d4d; + --color-text-tertiary: #666666; + --color-border: rgba(0, 0, 0, 0.08); + --color-border-secondary: rgba(0, 0, 0, 0.04); + --color-bg-container-alpha: rgba(255, 255, 255, 0.85); + --color-bg-elevated-alpha: rgba(250, 250, 250, 0.7); + --color-bg-elevated-hover: rgba(235, 235, 235, 0.9); + --color-primary-alpha-low: rgba(23, 23, 23, 0.1); + --color-primary-alpha-medium: rgba(23, 23, 23, 0.3); + --color-primary-alpha-hover: rgba(23, 23, 23, 0.12); + --color-primary-shadow: rgba(23, 23, 23, 0.1); + --color-overlay: rgba(0, 0, 0, 0.5); + --color-overlay-border: rgba(255, 255, 255, 0.2); + + /* Vercel工作流颜色 */ + --ship-red: #ff5b4f; + --preview-pink: #de1d8d; + --develop-blue: #0a72ef; + --link-blue: #0072f5; + --focus-blue: hsla(212, 100%, 48%, 1); + + /* 阴影边框系统 */ + --shadow-border: 0px 0px 0px 1px rgba(0, 0, 0, 0.08); + --shadow-border-light: 0px 0px 0px 1px rgb(235, 235, 235); + --shadow-card: rgba(0, 0, 0, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 0px 2px 2px, rgba(0, 0, 0, 0.04) 0px 8px 8px -8px, #fafafa 0px 0px 0px 1px; + --shadow-subtle: rgba(0, 0, 0, 0.04) 0px 2px 2px; } body, @@ -31,30 +60,25 @@ html, height: 100%; color: var(--color-text); background: var(--color-bg); - font-family: 'Righteous', 'Poppins', 'Inter', 'Roboto', 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; - letter-spacing: 0.02em; + font-family: 'Geist', 'Inter', 'Roboto', 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-feature-settings: 'liga' 1; font-size: 16px; - transition: - background 0.3s, - color 0.3s; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } * { box-sizing: border-box; - transition: - background 0.3s, - color 0.3s, - box-shadow 0.3s, - border-color 0.3s; } html { scrollbar-width: thin; - scrollbar-color: var(--color-border) transparent; + scrollbar-color: rgba(0, 0, 0, 0.1) transparent; ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { @@ -62,17 +86,17 @@ html { } ::-webkit-scrollbar-thumb { - border-radius: 8px; - background-color: var(--color-border); + border-radius: 3px; + background-color: rgba(0, 0, 0, 0.1); } ::-webkit-scrollbar-thumb:hover { cursor: pointer; - background-color: var(--color-text-tertiary); + background-color: rgba(0, 0, 0, 0.2); } } ::selection { - background: var(--color-primary); + background: rgba(23, 23, 23, 0.1); color: var(--color-text); } diff --git a/apps/frontend/src/lib/store/useCmsStore.ts b/apps/frontend/src/lib/store/useCmsStore.ts new file mode 100644 index 0000000..3a8d8a4 --- /dev/null +++ b/apps/frontend/src/lib/store/useCmsStore.ts @@ -0,0 +1,146 @@ +import type { CustomCms, CreateCustomCmsInput, UpdateCustomCmsInput } from '@vodhub/shared/types/custom-cms'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface CmsStore { + // 状态 + cmsList: CustomCms[]; + isLoading: boolean; + error: string | null; + + // 操作 + fetchCmsList: () => Promise; + getCmsById: (id: string) => CustomCms | undefined; + addCms: (input: CreateCustomCmsInput) => Promise; + updateCms: (input: UpdateCustomCmsInput) => Promise; + deleteCms: (id: string) => Promise; + + // 工具 + clearError: () => void; + clearCmsList: () => void; +} + +const useCmsStore = create()( + persist( + (set, get) => ({ + // 初始状态 + cmsList: [], + isLoading: false, + error: null, + + // 获取CMS列表(从本地存储加载) + fetchCmsList: async () => { + set({ isLoading: true, error: null }); + try { + // 状态已通过persist中间件自动从localStorage恢复 + // 这里只需要确保状态已加载 + set({ isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : '加载CMS列表失败', + isLoading: false + }); + throw error; + } + }, + + // 根据ID获取CMS + getCmsById: (id) => { + return get().cmsList.find((cms) => cms.id === id); + }, + + // 添加CMS + addCms: async (input) => { + set({ isLoading: true, error: null }); + try { + const newCms: CustomCms = { + id: crypto.randomUUID(), + name: input.name, + url: input.url, + description: input.description || '', + enabled: input.enabled ?? true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + set((state) => ({ + cmsList: [...state.cmsList, newCms], + isLoading: false + })); + + return newCms; + } catch (error) { + set({ + error: error instanceof Error ? error.message : '添加CMS失败', + isLoading: false + }); + throw error; + } + }, + + // 更新CMS + updateCms: async (input) => { + set({ isLoading: true, error: null }); + try { + const existingCms = get().cmsList.find((cms) => cms.id === input.id); + if (!existingCms) { + throw new Error('CMS not found'); + } + + const updatedCms: CustomCms = { + ...existingCms, + name: input.name ?? existingCms.name, + url: input.url ?? existingCms.url, + description: input.description ?? existingCms.description, + enabled: input.enabled ?? existingCms.enabled, + updatedAt: new Date().toISOString() + }; + + set((state) => ({ + cmsList: state.cmsList.map((cms) => (cms.id === input.id ? updatedCms : cms)), + isLoading: false + })); + + return updatedCms; + } catch (error) { + set({ + error: error instanceof Error ? error.message : '更新CMS失败', + isLoading: false + }); + throw error; + } + }, + + // 删除CMS + deleteCms: async (id) => { + set({ isLoading: true, error: null }); + try { + set((state) => ({ + cmsList: state.cmsList.filter((cms) => cms.id !== id), + isLoading: false + })); + } catch (error) { + set({ + error: error instanceof Error ? error.message : '删除CMS失败', + isLoading: false + }); + throw error; + } + }, + + // 清除错误 + clearError: () => set({ error: null }), + + // 清除CMS列表 + clearCmsList: () => set({ cmsList: [] }) + }), + { + name: 'vodhub-cms-store', + partialize: (state) => ({ + cmsList: state.cmsList + }) + } + ) +); + +export default useCmsStore; diff --git a/apps/frontend/src/lib/store/useSettingStore.ts b/apps/frontend/src/lib/store/useSettingStore.ts index 553591b..bab9389 100644 --- a/apps/frontend/src/lib/store/useSettingStore.ts +++ b/apps/frontend/src/lib/store/useSettingStore.ts @@ -1,19 +1,30 @@ -import store from 'store2'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; -const useSettingStore = () => { - const setting = store.get('vod_next_setting') || {}; +interface SettingStore { + vod_hub_api: string; + site_name: string; + current_site: string; + updateSetting: (setting: { vod_hub_api: string; site_name: string; current_site: string }) => void; +} - const updateSetting = (setting: { vod_hub_api: string; site_name: string; current_site: string }) => { - store.set('vod_next_setting', setting); - }; - - return { - setting, - current_site: setting.current_site, - vod_hub_api: setting.vod_hub_api, - site_name: setting.site_name, - updateSetting - }; -}; +const useSettingStore = create()( + persist( + (set) => ({ + vod_hub_api: '', + site_name: '', + current_site: '', + updateSetting: (setting) => set(setting) + }), + { + name: 'vod_next_setting', + partialize: (state) => ({ + vod_hub_api: state.vod_hub_api, + site_name: state.site_name, + current_site: state.current_site + }) + } + ) +); export default useSettingStore; diff --git a/apps/frontend/src/lib/store/useThemeStore.ts b/apps/frontend/src/lib/store/useThemeStore.ts index c81134a..0f2e5eb 100644 --- a/apps/frontend/src/lib/store/useThemeStore.ts +++ b/apps/frontend/src/lib/store/useThemeStore.ts @@ -12,7 +12,7 @@ interface ThemeStore { export const useThemeStore = create()( persist( (set) => ({ - themeId: 'midnight' as ThemeId, + themeId: 'vercel' as ThemeId, setThemeId: (themeId: ThemeId) => set({ themeId }) }), { diff --git a/apps/frontend/src/lib/store/useVodSitesStore.ts b/apps/frontend/src/lib/store/useVodSitesStore.ts index c91db19..9bb010e 100644 --- a/apps/frontend/src/lib/store/useVodSitesStore.ts +++ b/apps/frontend/src/lib/store/useVodSitesStore.ts @@ -2,6 +2,8 @@ import { SelectProps } from 'antd'; import store from 'store2'; import { create } from 'zustand'; +import useCmsStore from './useCmsStore'; + import { namespaceApi } from '@/services'; interface VodSitesStore { @@ -10,13 +12,12 @@ interface VodSitesStore { isInitialized: boolean; // 是否初始化 sites: SelectProps['options']; setSites: (sites: VodSitesStore['sites']) => void; - getVodTypes: (options: { force: boolean }) => Promise; + getVodTypes: () => Promise; resetError: () => void; clearVodTypes: () => void; } const CACHE_KEY = 'vod_next_sites'; -const CACHE_DURATION = 1000 * 60 * 60; // 1小时 export const useVodSitesStore = create((set) => ({ hasError: false, @@ -28,18 +29,7 @@ export const useVodSitesStore = create((set) => ({ clearVodTypes: () => { set({ sites: [], isInitialized: false, hasError: false }); }, - getVodTypes: async ({ force = false }) => { - if (!force) { - // 检查缓存 - const cached = store.get(CACHE_KEY); - if (cached) { - const { data, timestamp } = cached; - if (Date.now() - timestamp < CACHE_DURATION) { - set({ sites: data, isInitialized: true }); - return; - } - } - } + getVodTypes: async () => { try { const res = await namespaceApi(); const newSites: SelectProps['options'] = []; @@ -50,6 +40,17 @@ export const useVodSitesStore = create((set) => ({ }); }); + // 获取启用的自定义CMS并添加到站点列表 + // 直接从 useCmsStore 获取,因为使用了 persist 中间件 + const cmsState = useCmsStore.getState(); + const enabledCms = cmsState.cmsList.filter((cms) => cms.enabled); + enabledCms.forEach((cms) => { + newSites.push({ + label: cms.name, + value: `custom_${cms.id}` + }); + }); + store.set(CACHE_KEY, { data: newSites, timestamp: Date.now() diff --git a/apps/frontend/src/lib/themes/index.ts b/apps/frontend/src/lib/themes/index.ts index 1ffd7f5..469a512 100644 --- a/apps/frontend/src/lib/themes/index.ts +++ b/apps/frontend/src/lib/themes/index.ts @@ -1,6 +1,6 @@ import { ThemeConfig } from 'antd'; -export type ThemeId = 'midnight' | 'aurora' | 'cyber'; +export type ThemeId = 'vercel' | 'airbnb' | 'claude'; export interface ThemeCssVariables { '--color-primary': string; @@ -39,365 +39,367 @@ export interface ThemeDefinition { } export const themes: Record = { - midnight: { - id: 'midnight', - name: '午夜玫瑰', - description: '深邃暗夜,优雅玫瑰红', - isDark: true, + vercel: { + id: 'vercel', + name: 'Vercel', + description: 'Vercel设计风格', + isDark: false, preview: { - primary: '#E11D48', - background: '#0F0F23', - accent: '#1E1B4B' + primary: '#171717', + background: '#ffffff', + accent: '#0072f5' }, cssVariables: { - '--color-primary': '#E11D48', - '--color-primary-light': '#FB7185', - '--color-bg': '#000000', - '--color-bg-container': '#0F0F23', - '--color-bg-elevated': '#1E1B4B', - '--color-text': '#F8FAFC', - '--color-text-secondary': '#94A3B8', - '--color-text-tertiary': '#64748B', - '--color-border': '#334155', - '--color-border-secondary': 'rgba(51, 65, 85, 0.5)', - '--color-bg-container-alpha': 'rgba(15, 15, 35, 0.6)', - '--color-bg-elevated-alpha': 'rgba(30, 27, 75, 0.5)', - '--color-bg-elevated-hover': 'rgba(30, 27, 75, 0.8)', - '--color-primary-alpha-low': 'rgba(225, 29, 72, 0.1)', - '--color-primary-alpha-medium': 'rgba(225, 29, 72, 0.3)', - '--color-primary-alpha-hover': 'rgba(225, 29, 72, 0.12)', - '--color-primary-shadow': 'rgba(225, 29, 72, 0.4)', - '--color-overlay': 'rgba(0, 0, 0, 0.7)', - '--color-overlay-border': 'rgba(255, 255, 255, 0.1)' + '--color-primary': '#171717', + '--color-primary-light': '#4d4d4d', + '--color-bg': '#ffffff', + '--color-bg-container': '#ffffff', + '--color-bg-elevated': '#fafafa', + '--color-text': '#171717', + '--color-text-secondary': '#4d4d4d', + '--color-text-tertiary': '#666666', + '--color-border': 'rgba(0, 0, 0, 0.08)', + '--color-border-secondary': 'rgba(0, 0, 0, 0.04)', + '--color-bg-container-alpha': 'rgba(255, 255, 255, 0.85)', + '--color-bg-elevated-alpha': 'rgba(250, 250, 250, 0.7)', + '--color-bg-elevated-hover': 'rgba(235, 235, 235, 0.9)', + '--color-primary-alpha-low': 'rgba(23, 23, 23, 0.1)', + '--color-primary-alpha-medium': 'rgba(23, 23, 23, 0.3)', + '--color-primary-alpha-hover': 'rgba(23, 23, 23, 0.12)', + '--color-primary-shadow': 'rgba(23, 23, 23, 0.1)', + '--color-overlay': 'rgba(0, 0, 0, 0.5)', + '--color-overlay-border': 'rgba(255, 255, 255, 0.2)' }, config: { algorithm: undefined, token: { - colorPrimary: '#E11D48', - colorBgContainer: '#0F0F23', - colorBgElevated: '#1E1B4B', - colorBgLayout: '#000000', - colorText: '#F8FAFC', - colorTextSecondary: '#94A3B8', - colorTextTertiary: '#64748B', - colorBorder: '#334155', - colorBorderSecondary: 'rgba(51, 65, 85, 0.5)', - borderRadius: 12, - fontFamily: '"Righteous", "Poppins", Inter, Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' + colorPrimary: '#171717', + colorBgContainer: '#ffffff', + colorBgElevated: '#fafafa', + colorBgLayout: '#ffffff', + colorText: '#171717', + colorTextSecondary: '#4d4d4d', + colorTextTertiary: '#666666', + colorBorder: 'rgba(0, 0, 0, 0.08)', + colorBorderSecondary: 'rgba(0, 0, 0, 0.04)', + borderRadius: 6, + fontFamily: '"Geist", "Inter", "Roboto", "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' }, components: { Button: { - colorPrimary: '#E11D48', - borderRadius: 8, - fontWeight: 600, - primaryColor: '#F8FAFC' + colorPrimary: '#171717', + borderRadius: 6, + fontWeight: 500, + primaryColor: '#ffffff' }, Card: { - borderRadiusLG: 16 + borderRadiusLG: 8 }, Input: { - hoverBorderColor: '#E11D48', - activeBorderColor: '#E11D48', - colorBgContainer: '#0F0F23', - colorBorder: '#334155', - colorText: '#F8FAFC', - colorTextPlaceholder: '#64748B', - activeShadow: '0 0 0 2px rgba(225, 29, 72, 0.2)' + hoverBorderColor: '#171717', + activeBorderColor: '#171717', + colorBgContainer: '#ffffff', + colorBorder: 'rgba(0, 0, 0, 0.08)', + colorText: '#171717', + colorTextPlaceholder: '#666666', + activeShadow: '0 0 0 1px rgba(23, 23, 23, 0.2)' }, Layout: { - bodyBg: '#000000', - headerBg: '#0F0F23', - footerBg: '#000000', - siderBg: '#0F0F23' + bodyBg: '#ffffff', + headerBg: '#ffffff', + footerBg: '#ffffff', + siderBg: '#ffffff' }, Collapse: { headerPadding: 0, contentPadding: 0 }, Tag: { - colorBgContainer: '#1E1B4B', - colorText: '#94A3B8' + colorBgContainer: '#ebf5ff', + colorText: '#0068d6' }, Select: { - colorBgContainer: '#0F0F23', - colorBorder: '#334155', - colorText: '#F8FAFC', - colorTextPlaceholder: '#64748B', - optionSelectedBg: 'rgba(225, 29, 72, 0.1)', - optionSelectedColor: '#E11D48', - optionActiveBg: 'rgba(30, 27, 75, 0.8)', - selectorBg: '#0F0F23' + colorBgContainer: '#ffffff', + colorBorder: 'rgba(0, 0, 0, 0.08)', + colorText: '#171717', + colorTextPlaceholder: '#666666', + optionSelectedBg: 'rgba(23, 23, 23, 0.1)', + optionSelectedColor: '#171717', + optionActiveBg: 'rgba(235, 235, 235, 0.8)', + selectorBg: '#ffffff' }, Tabs: { - itemColor: '#64748B', - itemSelectedColor: '#E11D48', - itemHoverColor: '#F8FAFC', - inkBarColor: '#E11D48', - itemActiveColor: '#E11D48' + itemColor: '#666666', + itemSelectedColor: '#171717', + itemHoverColor: '#171717', + inkBarColor: '#171717', + itemActiveColor: '#171717' }, Steps: { - colorPrimary: '#E11D48', - colorText: '#F8FAFC', - colorTextDescription: '#64748B', - colorIcon: '#64748B', - colorPrimaryBorder: '#E11D48' + colorPrimary: '#171717', + colorText: '#171717', + colorTextDescription: '#666666', + colorIcon: '#666666', + colorPrimaryBorder: '#171717' }, Descriptions: { - colorText: '#F8FAFC', - colorTextSecondary: '#94A3B8', - colorTextTertiary: '#64748B', - labelColor: '#64748B', - contentColor: '#94A3B8' + colorText: '#171717', + colorTextSecondary: '#4d4d4d', + colorTextTertiary: '#666666', + labelColor: '#666666', + contentColor: '#4d4d4d' }, Typography: { - colorText: '#94A3B8', - colorTextSecondary: '#64748B' + colorText: '#4d4d4d', + colorTextSecondary: '#666666' }, Form: { - labelColor: '#94A3B8', - labelRequiredMarkColor: '#E11D48' + labelColor: '#4d4d4d', + labelRequiredMarkColor: '#171717' } } } }, - aurora: { - id: 'aurora', - name: '极光清新', - description: '清新明亮,极光配色', + airbnb: { + id: 'airbnb', + name: 'Airbnb', + description: 'Airbnb设计风格,纯白背景,Rausch红强调色', isDark: false, preview: { - primary: '#0891B2', - background: '#F0FDFA', - accent: '#CCFBF1' + primary: '#ff385c', + background: '#ffffff', + accent: '#f2f2f2' }, cssVariables: { - '--color-primary': '#0891B2', - '--color-primary-light': '#22D3EE', - '--color-bg': '#F8FAFC', - '--color-bg-container': '#FFFFFF', - '--color-bg-elevated': '#F0FDFA', - '--color-text': '#0F172A', - '--color-text-secondary': '#475569', - '--color-text-tertiary': '#64748B', - '--color-border': '#E2E8F0', - '--color-border-secondary': '#F1F5F9', + '--color-primary': '#ff385c', + '--color-primary-light': '#ff6b8b', + '--color-bg': '#ffffff', + '--color-bg-container': '#ffffff', + '--color-bg-elevated': '#f2f2f2', + '--color-text': '#222222', + '--color-text-secondary': '#6a6a6a', + '--color-text-tertiary': '#929292', + '--color-border': '#c1c1c1', + '--color-border-secondary': '#e8e8e8', '--color-bg-container-alpha': 'rgba(255, 255, 255, 0.85)', - '--color-bg-elevated-alpha': 'rgba(240, 253, 250, 0.7)', - '--color-bg-elevated-hover': 'rgba(204, 251, 241, 0.9)', - '--color-primary-alpha-low': 'rgba(8, 145, 178, 0.1)', - '--color-primary-alpha-medium': 'rgba(8, 145, 178, 0.3)', - '--color-primary-alpha-hover': 'rgba(8, 145, 178, 0.12)', - '--color-primary-shadow': 'rgba(8, 145, 178, 0.3)', + '--color-bg-elevated-alpha': 'rgba(242, 242, 242, 0.7)', + '--color-bg-elevated-hover': 'rgba(232, 232, 232, 0.9)', + '--color-primary-alpha-low': 'rgba(255, 56, 92, 0.1)', + '--color-primary-alpha-medium': 'rgba(255, 56, 92, 0.3)', + '--color-primary-alpha-hover': 'rgba(255, 56, 92, 0.12)', + '--color-primary-shadow': 'rgba(255, 56, 92, 0.4)', '--color-overlay': 'rgba(0, 0, 0, 0.5)', '--color-overlay-border': 'rgba(255, 255, 255, 0.2)' }, config: { algorithm: undefined, token: { - colorPrimary: '#0891B2', - colorBgContainer: '#FFFFFF', - colorBgElevated: '#F0FDFA', - colorBgLayout: '#F8FAFC', - colorText: '#0F172A', - colorTextSecondary: '#475569', - colorTextTertiary: '#64748B', - colorBorder: '#E2E8F0', - colorBorderSecondary: '#F1F5F9', + colorPrimary: '#ff385c', + colorBgContainer: '#ffffff', + colorBgElevated: '#f2f2f2', + colorBgLayout: '#ffffff', + colorText: '#222222', + colorTextSecondary: '#6a6a6a', + colorTextTertiary: '#929292', + colorBorder: '#c1c1c1', + colorBorderSecondary: '#e8e8e8', borderRadius: 12, - fontFamily: '"Righteous", "Poppins", Inter, Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' + fontFamily: '"Geist", "Inter", "Roboto", "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' }, components: { Button: { - colorPrimary: '#0891B2', + colorPrimary: '#ff385c', borderRadius: 8, fontWeight: 600, - primaryColor: '#FFFFFF' + primaryColor: '#ffffff' }, Card: { - borderRadiusLG: 16 + borderRadiusLG: 20, + boxShadow: 'rgba(0,0,0,0.02) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 6px, rgba(0,0,0,0.1) 0px 4px 8px' }, Input: { - hoverBorderColor: '#0891B2', - activeBorderColor: '#0891B2', - colorBgContainer: '#FFFFFF', - colorBorder: '#E2E8F0', - colorText: '#0F172A', - colorTextPlaceholder: '#94A3B8', - activeShadow: '0 0 0 2px rgba(8, 145, 178, 0.2)' + hoverBorderColor: '#ff385c', + activeBorderColor: '#ff385c', + colorBgContainer: '#ffffff', + colorBorder: '#c1c1c1', + colorText: '#222222', + colorTextPlaceholder: '#929292', + activeShadow: '0 0 0 2px rgba(255, 56, 92, 0.2)' }, Layout: { - bodyBg: '#F8FAFC', - headerBg: '#FFFFFF', - footerBg: '#F8FAFC', - siderBg: '#FFFFFF' + bodyBg: '#ffffff', + headerBg: '#ffffff', + footerBg: '#ffffff', + siderBg: '#ffffff' }, Collapse: { headerPadding: 0, contentPadding: 0 }, Tag: { - colorBgContainer: '#CCFBF1', - colorText: '#0F766E' + colorBgContainer: '#f2f2f2', + colorText: '#6a6a6a' }, Select: { - colorBgContainer: '#FFFFFF', - colorBorder: '#E2E8F0', - colorText: '#0F172A', - colorTextPlaceholder: '#94A3B8', - optionSelectedBg: 'rgba(8, 145, 178, 0.1)', - optionSelectedColor: '#0891B2', - optionActiveBg: 'rgba(204, 251, 241, 0.8)', - selectorBg: '#FFFFFF' + colorBgContainer: '#ffffff', + colorBorder: '#c1c1c1', + colorText: '#222222', + colorTextPlaceholder: '#929292', + optionSelectedBg: 'rgba(255, 56, 92, 0.1)', + optionSelectedColor: '#ff385c', + optionActiveBg: 'rgba(242, 242, 242, 0.8)', + selectorBg: '#ffffff' }, Tabs: { - itemColor: '#64748B', - itemSelectedColor: '#0891B2', - itemHoverColor: '#0F172A', - inkBarColor: '#0891B2', - itemActiveColor: '#0891B2' + itemColor: '#929292', + itemSelectedColor: '#ff385c', + itemHoverColor: '#222222', + inkBarColor: '#ff385c', + itemActiveColor: '#ff385c' }, Steps: { - colorPrimary: '#0891B2', - colorText: '#0F172A', - colorTextDescription: '#64748B', - colorIcon: '#64748B', - colorPrimaryBorder: '#0891B2' + colorPrimary: '#ff385c', + colorText: '#222222', + colorTextDescription: '#929292', + colorIcon: '#929292', + colorPrimaryBorder: '#ff385c' }, Descriptions: { - colorText: '#0F172A', - colorTextSecondary: '#475569', - colorTextTertiary: '#64748B', - labelColor: '#64748B', - contentColor: '#475569' + colorText: '#222222', + colorTextSecondary: '#6a6a6a', + colorTextTertiary: '#929292', + labelColor: '#929292', + contentColor: '#6a6a6a' }, Typography: { - colorText: '#475569', - colorTextSecondary: '#64748B' + colorText: '#6a6a6a', + colorTextSecondary: '#929292' }, Form: { - labelColor: '#475569', - labelRequiredMarkColor: '#0891B2' + labelColor: '#6a6a6a', + labelRequiredMarkColor: '#ff385c' } } } }, - cyber: { - id: 'cyber', - name: '赛博霓虹', - description: '未来科技,霓虹紫光', - isDark: true, + claude: { + id: 'claude', + name: 'Claude', + description: 'Claude设计风格,温暖中性色系,陶红色强调色', + isDark: false, preview: { - primary: '#7C3AED', - background: '#0F0F23', - accent: '#1E1B4B' + primary: '#c96442', + background: '#f5f4ed', + accent: '#e8e6dc' }, cssVariables: { - '--color-primary': '#7C3AED', - '--color-primary-light': '#A78BFA', - '--color-bg': '#000000', - '--color-bg-container': '#0F0F23', - '--color-bg-elevated': '#1E1B4B', - '--color-text': '#E2E8F0', - '--color-text-secondary': '#A5B4FC', - '--color-text-tertiary': '#818CF8', - '--color-border': '#312E81', - '--color-border-secondary': 'rgba(49, 46, 129, 0.5)', - '--color-bg-container-alpha': 'rgba(15, 15, 35, 0.6)', - '--color-bg-elevated-alpha': 'rgba(30, 27, 75, 0.5)', - '--color-bg-elevated-hover': 'rgba(30, 27, 75, 0.8)', - '--color-primary-alpha-low': 'rgba(124, 58, 237, 0.1)', - '--color-primary-alpha-medium': 'rgba(124, 58, 237, 0.3)', - '--color-primary-alpha-hover': 'rgba(124, 58, 237, 0.12)', - '--color-primary-shadow': 'rgba(124, 58, 237, 0.4)', - '--color-overlay': 'rgba(0, 0, 0, 0.7)', - '--color-overlay-border': 'rgba(255, 255, 255, 0.1)' + '--color-primary': '#c96442', + '--color-primary-light': '#d97757', + '--color-bg': '#f5f4ed', + '--color-bg-container': '#faf9f5', + '--color-bg-elevated': '#e8e6dc', + '--color-text': '#141413', + '--color-text-secondary': '#5e5d59', + '--color-text-tertiary': '#87867f', + '--color-border': '#f0eee6', + '--color-border-secondary': '#e8e6dc', + '--color-bg-container-alpha': 'rgba(250, 249, 245, 0.85)', + '--color-bg-elevated-alpha': 'rgba(232, 230, 220, 0.7)', + '--color-bg-elevated-hover': 'rgba(232, 230, 220, 0.9)', + '--color-primary-alpha-low': 'rgba(201, 100, 66, 0.1)', + '--color-primary-alpha-medium': 'rgba(201, 100, 66, 0.3)', + '--color-primary-alpha-hover': 'rgba(201, 100, 66, 0.12)', + '--color-primary-shadow': 'rgba(201, 100, 66, 0.4)', + '--color-overlay': 'rgba(0, 0, 0, 0.5)', + '--color-overlay-border': 'rgba(255, 255, 255, 0.2)' }, config: { algorithm: undefined, token: { - colorPrimary: '#7C3AED', - colorBgContainer: '#0F0F23', - colorBgElevated: '#1E1B4B', - colorBgLayout: '#000000', - colorText: '#E2E8F0', - colorTextSecondary: '#A5B4FC', - colorTextTertiary: '#818CF8', - colorBorder: '#312E81', - colorBorderSecondary: 'rgba(49, 46, 129, 0.5)', + colorPrimary: '#c96442', + colorBgContainer: '#faf9f5', + colorBgElevated: '#e8e6dc', + colorBgLayout: '#f5f4ed', + colorText: '#141413', + colorTextSecondary: '#5e5d59', + colorTextTertiary: '#87867f', + colorBorder: '#f0eee6', + colorBorderSecondary: '#e8e6dc', borderRadius: 12, - fontFamily: '"Inter", "Poppins", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' + fontFamily: '"Geist", "Inter", "Roboto", "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif' }, components: { Button: { - colorPrimary: '#7C3AED', + colorPrimary: '#c96442', borderRadius: 8, fontWeight: 600, - primaryColor: '#FFFFFF' + primaryColor: '#ffffff' }, Card: { - borderRadiusLG: 16 + borderRadiusLG: 16, + boxShadow: 'rgba(0,0,0,0.05) 0px 4px 24px' }, Input: { - hoverBorderColor: '#7C3AED', - activeBorderColor: '#7C3AED', - colorBgContainer: '#0F0F23', - colorBorder: '#312E81', - colorText: '#E2E8F0', - colorTextPlaceholder: '#818CF8', - activeShadow: '0 0 0 2px rgba(124, 58, 237, 0.2)' + hoverBorderColor: '#c96442', + activeBorderColor: '#c96442', + colorBgContainer: '#faf9f5', + colorBorder: '#f0eee6', + colorText: '#141413', + colorTextPlaceholder: '#87867f', + activeShadow: '0 0 0 2px rgba(201, 100, 66, 0.2)' }, Layout: { - bodyBg: '#000000', - headerBg: '#0F0F23', - footerBg: '#000000', - siderBg: '#0F0F23' + bodyBg: '#f5f4ed', + headerBg: '#faf9f5', + footerBg: '#f5f4ed', + siderBg: '#faf9f5' }, Collapse: { headerPadding: 0, contentPadding: 0 }, Tag: { - colorBgContainer: '#1E1B4B', - colorText: '#A5B4FC' + colorBgContainer: '#e8e6dc', + colorText: '#5e5d59' }, Select: { - colorBgContainer: '#0F0F23', - colorBorder: '#312E81', - colorText: '#E2E8F0', - colorTextPlaceholder: '#818CF8', - optionSelectedBg: 'rgba(124, 58, 237, 0.1)', - optionSelectedColor: '#7C3AED', - optionActiveBg: 'rgba(30, 27, 75, 0.8)', - selectorBg: '#0F0F23' + colorBgContainer: '#faf9f5', + colorBorder: '#f0eee6', + colorText: '#141413', + colorTextPlaceholder: '#87867f', + optionSelectedBg: 'rgba(201, 100, 66, 0.1)', + optionSelectedColor: '#c96442', + optionActiveBg: 'rgba(232, 230, 220, 0.8)', + selectorBg: '#faf9f5' }, Tabs: { - itemColor: '#818CF8', - itemSelectedColor: '#7C3AED', - itemHoverColor: '#E2E8F0', - inkBarColor: '#7C3AED', - itemActiveColor: '#7C3AED' + itemColor: '#87867f', + itemSelectedColor: '#c96442', + itemHoverColor: '#141413', + inkBarColor: '#c96442', + itemActiveColor: '#c96442' }, Steps: { - colorPrimary: '#7C3AED', - colorText: '#E2E8F0', - colorTextDescription: '#818CF8', - colorIcon: '#818CF8', - colorPrimaryBorder: '#7C3AED' + colorPrimary: '#c96442', + colorText: '#141413', + colorTextDescription: '#87867f', + colorIcon: '#87867f', + colorPrimaryBorder: '#c96442' }, Descriptions: { - colorText: '#E2E8F0', - colorTextSecondary: '#A5B4FC', - colorTextTertiary: '#818CF8', - labelColor: '#818CF8', - contentColor: '#A5B4FC' + colorText: '#141413', + colorTextSecondary: '#5e5d59', + colorTextTertiary: '#87867f', + labelColor: '#87867f', + contentColor: '#5e5d59' }, Typography: { - colorText: '#A5B4FC', - colorTextSecondary: '#818CF8' + colorText: '#5e5d59', + colorTextSecondary: '#87867f' }, Form: { - labelColor: '#A5B4FC', - labelRequiredMarkColor: '#7C3AED' + labelColor: '#5e5d59', + labelRequiredMarkColor: '#c96442' } } } @@ -405,7 +407,7 @@ export const themes: Record = { }; export const getTheme = (themeId: ThemeId): ThemeDefinition => { - return themes[themeId] || themes.midnight; + return themes[themeId] || themes.vercel; }; export const themeList: ThemeDefinition[] = Object.values(themes); diff --git a/apps/frontend/src/pages/category/index.module.scss b/apps/frontend/src/pages/category/index.module.scss index 0992af5..c5c64dc 100644 --- a/apps/frontend/src/pages/category/index.module.scss +++ b/apps/frontend/src/pages/category/index.module.scss @@ -3,9 +3,9 @@ width: 100%; &-filters { - background: var(--color-bg-container-alpha); - border-radius: 12px; - border: 1px solid var(--color-border); + background: var(--color-bg-container); + border-radius: 8px; + box-shadow: var(--shadow-border); margin-bottom: 24px; overflow: hidden; } @@ -25,7 +25,8 @@ &-label { flex-shrink: 0; color: var(--color-text-tertiary); - font-size: 13px; + font-size: 12px; + font-weight: 500; min-width: 48px; padding-top: 4px; } @@ -39,12 +40,13 @@ &-option { padding: 4px 12px; border-radius: 6px; - font-size: 13px; + font-size: 12px; + font-weight: 400; cursor: pointer; transition: all 0.2s; color: var(--color-text-secondary); - background: var(--color-bg-elevated-alpha); - border: 1px solid transparent; + background: transparent; + box-shadow: var(--shadow-border); &:hover { color: var(--color-text); @@ -54,7 +56,7 @@ &.active { color: var(--color-primary); background: var(--color-primary-alpha-low); - border-color: var(--color-primary-alpha-medium); + box-shadow: 0px 0px 0px 1px rgba(23, 23, 23, 0.2); } } } diff --git a/apps/frontend/src/pages/detail/index.module.scss b/apps/frontend/src/pages/detail/index.module.scss index d2ec2d1..e5e2268 100644 --- a/apps/frontend/src/pages/detail/index.module.scss +++ b/apps/frontend/src/pages/detail/index.module.scss @@ -4,15 +4,15 @@ &-player { background: var(--color-bg-container); - border-radius: 16px; + border-radius: 12px; overflow: hidden; - border: 1px solid var(--color-border); + box-shadow: var(--shadow-border); } &-playlist { - background: var(--color-bg-container-alpha); - border-radius: 16px; - border: 1px solid var(--color-border); + background: var(--color-bg-container); + border-radius: 8px; + box-shadow: var(--shadow-border); padding: 20px; } @@ -27,10 +27,12 @@ font-size: 18px; font-weight: 600; color: var(--color-text); + letter-spacing: -0.32px; } &-source { - font-size: 13px; + font-size: 12px; + font-weight: 400; color: var(--color-text-tertiary); } @@ -45,14 +47,15 @@ &-episode { flex: 0 0 calc(33.33% - 6px); padding: 10px 12px; - background: var(--color-bg-elevated-alpha); - border-radius: 8px; - font-size: 13px; + background: var(--color-bg-elevated); + border-radius: 6px; + font-size: 12px; + font-weight: 400; color: var(--color-text-secondary); cursor: pointer; transition: all 0.2s; text-align: center; - border: 1px solid transparent; + box-shadow: var(--shadow-border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -65,14 +68,14 @@ &.active { color: var(--color-primary); background: var(--color-primary-alpha-low); - border-color: var(--color-primary-alpha-medium); + box-shadow: 0px 0px 0px 1px rgba(23, 23, 23, 0.2); } } &-info { - background: var(--color-bg-container-alpha); - border-radius: 16px; - border: 1px solid var(--color-border); + background: var(--color-bg-container); + border-radius: 8px; + box-shadow: var(--shadow-border); padding: 20px; margin-top: 24px; } diff --git a/apps/frontend/src/pages/home/index.module.scss b/apps/frontend/src/pages/home/index.module.scss index e173c07..9c6f751 100644 --- a/apps/frontend/src/pages/home/index.module.scss +++ b/apps/frontend/src/pages/home/index.module.scss @@ -19,9 +19,10 @@ &-title { font-size: 24px; - font-weight: 700; + font-weight: 600; color: var(--color-text); - letter-spacing: 0.02em; + letter-spacing: -0.96px; + line-height: 1.33; position: relative; display: inline-flex; align-items: center; @@ -31,17 +32,16 @@ content: ''; width: 4px; height: 24px; - background: linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-light) 100%); + background: var(--color-primary); border-radius: 2px; - box-shadow: 0 0 12px rgba(225, 29, 72, 0.5); } } &-more { - font-size: 13px; + font-size: 14px; + font-weight: 500; color: var(--color-text-tertiary); cursor: pointer; - transition: color 0.2s; display: flex; align-items: center; gap: 4px; diff --git a/apps/frontend/src/pages/home/index.tsx b/apps/frontend/src/pages/home/index.tsx index 8108812..078485d 100644 --- a/apps/frontend/src/pages/home/index.tsx +++ b/apps/frontend/src/pages/home/index.tsx @@ -76,9 +76,9 @@ const HomePage: React.FC = () => { ) : (
- - - + + + 暂无数据
diff --git a/apps/frontend/src/pages/setting/index.module.scss b/apps/frontend/src/pages/setting/index.module.scss index 885e8f1..4c09b90 100644 --- a/apps/frontend/src/pages/setting/index.module.scss +++ b/apps/frontend/src/pages/setting/index.module.scss @@ -1,16 +1,49 @@ .vod-next-setting { height: 100%; width: 100%; - max-width: 800px; margin: 0 auto; - padding: 24px !important; + padding: 24px; + background: var(--color-bg); +} + +.setting-page { + max-width: 1200px; + margin: 0 auto; +} + +.setting-card { + background: var(--color-bg-container) !important; + border-radius: 8px !important; + box-shadow: var(--shadow-border) !important; + border: none !important; + transition: all 0.2s ease; + + &:hover { + box-shadow: var(--shadow-card) !important; + } + + .ant-card-head { + border-bottom: 1px solid var(--color-border) !important; + padding: 16px 24px !important; + min-height: auto !important; + } + + .ant-card-head-title { + padding: 0 !important; + font-family: 'Geist', 'Inter', sans-serif; + font-feature-settings: 'liga' 1; + } + + .ant-card-body { + padding: 24px !important; + } } .setting-form-content { color: var(--color-text-tertiary) !important; background-color: var(--color-bg-container) !important; - border-radius: 16px !important; - border: 1px solid var(--color-border) !important; + border-radius: 8px !important; + box-shadow: var(--shadow-border) !important; margin-top: 16px !important; padding: 24px !important; } @@ -21,4 +54,75 @@ .setting-divider { margin: 32px 0 24px; + border-color: var(--color-border) !important; +} + +/* 表单样式覆盖 */ +:global(.ant-form-item-label) { + label { + font-family: 'Geist', 'Inter', sans-serif; + font-feature-settings: 'liga' 1; + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + } +} + +:global(.ant-input), +:global(.ant-select-selector) { + border-radius: 6px !important; + border: 1px solid var(--color-border) !important; + background: var(--color-bg-container) !important; + color: var(--color-text) !important; + font-family: 'Geist', 'Inter', sans-serif; + font-feature-settings: 'liga' 1; + + &:hover { + border-color: var(--color-text-tertiary) !important; + } + + &:focus { + border-color: var(--color-primary) !important; + box-shadow: 0 0 0 2px rgba(23, 23, 23, 0.1) !important; + } +} + +:global(.ant-input::placeholder), +:global(.ant-select-selection-placeholder) { + color: var(--color-text-tertiary) !important; +} + +:global(.ant-btn) { + border-radius: 6px !important; + font-family: 'Geist', 'Inter', sans-serif; + font-feature-settings: 'liga' 1; + font-weight: 500; + transition: all 0.2s ease; +} + +:global(.ant-btn.ant-btn-default) { + background: var(--color-bg-container) !important; + border: 1px solid var(--color-border) !important; + color: var(--color-text) !important; + + &:hover { + background: var(--color-bg-elevated) !important; + border-color: var(--color-text-tertiary) !important; + } +} + +:global(.ant-btn.ant-btn-primary) { + background: var(--color-primary) !important; + border-color: var(--color-primary) !important; + color: #ffffff !important; + + &:hover { + background: var(--color-primary-light) !important; + border-color: var(--color-primary-light) !important; + } +} + +:global(.ant-card) { + font-family: 'Geist', 'Inter', sans-serif; + font-feature-settings: 'liga' 1; } diff --git a/apps/frontend/src/pages/setting/index.tsx b/apps/frontend/src/pages/setting/index.tsx index ba6fcba..85453e1 100644 --- a/apps/frontend/src/pages/setting/index.tsx +++ b/apps/frontend/src/pages/setting/index.tsx @@ -1,25 +1,30 @@ -import { InfoCircleOutlined } from '@ant-design/icons'; -import { Form, Input, Button, Flex, Select, App, Steps, Descriptions, Divider } from 'antd'; -import React, { useState } from 'react'; +import { Form, Input, Button, Flex, Select, App, Card, Typography, Space } from 'antd'; +import React, { useEffect } from 'react'; import { useNavigate } from 'react-router'; import styles from './index.module.scss'; +import CmsManagement from '@/components/cms-management'; import { ThemeSelector } from '@/components/ui/ThemeSelector'; import useSettingStore from '@/lib/store/useSettingStore'; import { useVodSitesStore } from '@/lib/store/useVodSitesStore'; +const { Text } = Typography; + const SettingPage: React.FC = () => { - const { vod_hub_api, site_name, setting, updateSetting } = useSettingStore(); + const { vod_hub_api, site_name, current_site, updateSetting } = useSettingStore(); const [form] = Form.useForm(); const { getVodTypes, sites, isInitialized, hasError, clearVodTypes } = useVodSitesStore(); - const [current, setCurrent] = useState(0); - const { message } = App.useApp(); const navigate = useNavigate(); + // 同步 current_site 变化到表单 + useEffect(() => { + form.setFieldValue('current_site', current_site || ''); + }, [current_site, form]); + const handleSubmit = async () => { try { if (!isInitialized) { @@ -43,198 +48,212 @@ const SettingPage: React.FC = () => { } }; - const next = async () => { + const handleApiChange = async () => { + const apiValue = form.getFieldValue('vod_hub_api'); + if (!apiValue) { + message.warning('请输入 API 地址'); + return; + } + + updateSetting({ vod_hub_api: apiValue, site_name, current_site: '' }); + form.setFieldValue('current_site', ''); + clearVodTypes(); + try { - await form.validateFields(); - setCurrent(current + 1); + await getVodTypes(); + message.success('API 地址验证成功'); } catch (error) { - console.log('验证失败:', error); + console.error(error); + message.error('验证接口失败'); } }; - const prev = () => { - setCurrent(current - 1); - }; + return ( +
+
+ + {/* 操作按钮 */} + + + - const steps = [ - { - title: '基础设置', - content: ( - <> - - - - - - 支持完整的 HTTP(S) URL 或以 / 开头的相对路径 - - - ), - icon: + + + + + + + {/* 基础配置卡片 */} + + 基础配置 + + } + className={styles['setting-card']} + styles={{ + body: { padding: '24px' } }} - required - name="vod_hub_api" - rules={[ - { - validator: (_, value) => { - if (!value) { - return Promise.reject('请输入 API 地址'); - } - if (value.startsWith('/') || /^https?:\/\//.test(value)) { - return Promise.resolve(); + > +
{ + if ('current_site' in changedValues) { + if (changedValues.current_site) { + updateSetting({ vod_hub_api, site_name, current_site: changedValues.current_site }); } - return Promise.reject('请输入正确的 URL 或以 / 开头的路径'); } - } - ]} - > - - - - ), - buttons: ( - <> - - - ) - }, - { - title: '验证配置', - content: isInitialized ? ( - - + + + VodHub API 地址} + name="vod_hub_api" + rules={[ + { + validator: (_, value) => { + if (!value) { + return Promise.reject('请输入 API 地址'); + } + if (value.startsWith('/') || /^https?:\/\//.test(value)) { + return Promise.resolve(); + } + return Promise.reject('请输入正确的 URL 或以 / 开头的路径'); + } + } + ]} + style={{ marginBottom: 0 }} + > + + + + + + + + + {/* CMS地址管理 */} + + CMS地址管理 + + } + className={styles['setting-card']} + styles={{ + body: { padding: '24px' } }} > - 更新资源 - - - - ) - }, - { - title: '保存配置', - content: ( - item.value === form.getFieldValue('current_site'))?.label || '' - } - ]} - /> - ), - buttons: ( - - - - - - ) - } - ]; +
+ + 默认资源站点} name="current_site" style={{ marginBottom: 0 }}> +