first commit
3
.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_TENANT_SLUG=zitadel-example
|
||||||
|
VITE_TENANT_API_KEY=tenant_4_PBA8ymVYxHXe_0-Xe7f4akUZZ7nuIygHZupKppem
|
||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
134
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'deploy-*' # 只在推送 deploy-* 标签时触发
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Parse tag to env
|
||||||
|
run: |
|
||||||
|
# 获取 tag 名称(Gitea Actions 使用 GITHUB_REF_NAME)
|
||||||
|
TAG_NAME="${GITHUB_REF_NAME}"
|
||||||
|
echo "TAG_NAME=$TAG_NAME"
|
||||||
|
|
||||||
|
# Tag 格式: deploy-{project_name}-{deploymentId_no_dashes}
|
||||||
|
# 例如: deploy-b7ea026a-cf09-4e31-9f29-b55d7c652b71-123e4567e89b12d3a456426614174000
|
||||||
|
|
||||||
|
# 去掉 "deploy-" 前缀
|
||||||
|
PREFIX="deploy-"
|
||||||
|
REST="${TAG_NAME#$PREFIX}"
|
||||||
|
|
||||||
|
# deploymentId(无破折号)固定是最后32个字符
|
||||||
|
DEPLOYMENT_ID="${REST: -32}"
|
||||||
|
|
||||||
|
# project_name 是剩余部分(去掉最后的 "-" 和 deploymentId)
|
||||||
|
PROJECT_NAME="${REST%-${DEPLOYMENT_ID}}"
|
||||||
|
|
||||||
|
echo "PROJECT_NAME=$PROJECT_NAME" >> "$GITHUB_ENV"
|
||||||
|
echo "DEPLOYMENT_ID=$DEPLOYMENT_ID" >> "$GITHUB_ENV"
|
||||||
|
#echo "DOMAIN=${PROJECT_NAME}-preview.turingflowai.com" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# 调试输出
|
||||||
|
echo "Parsed PROJECT_NAME: $PROJECT_NAME"
|
||||||
|
echo "Parsed DEPLOYMENT_ID: $DEPLOYMENT_ID"
|
||||||
|
|
||||||
|
- name: Check toolchain (debug only, 可选)
|
||||||
|
run: |
|
||||||
|
node -v || echo "node not found"
|
||||||
|
npm -v || echo "npm not found"
|
||||||
|
curl --version || echo "curl not found"
|
||||||
|
|
||||||
|
# 已经在 node:20-bookworm-slim 容器内,无需再 setup-node
|
||||||
|
# - name: Setup Node
|
||||||
|
# uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: '20'
|
||||||
|
|
||||||
|
- name: Use CN npm registry
|
||||||
|
run: |
|
||||||
|
npm config set registry http://repo.myhuaweicloud.com/repository/npm/
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||||
|
PROJECT_NAME: ${{ env.PROJECT_NAME }}
|
||||||
|
DOMAIN: ${{ env.DOMAIN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[deploy] project: $PROJECT_NAME"
|
||||||
|
echo "[deploy] domain: $DOMAIN"
|
||||||
|
|
||||||
|
# 部署到 Cloudflare Pages (假定构建产物在 dist/)
|
||||||
|
npx wrangler pages deploy dist \
|
||||||
|
--project-name "$PROJECT_NAME" \
|
||||||
|
--branch main
|
||||||
|
|
||||||
|
# 绑定自定义域名:<project_name>-preview.turingflowai.com
|
||||||
|
#curl --fail -X POST \
|
||||||
|
# "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/domains" \
|
||||||
|
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||||
|
# -H "Content-Type: application/json" \
|
||||||
|
# -d '{"name":"'"${DOMAIN}"'"}' \
|
||||||
|
# || true
|
||||||
|
|
||||||
|
- name: Notify Deploy Service (success)
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
DEPLOY_SERVICE_CALLBACK_URL: ${{ secrets.DEPLOY_SERVICE_CALLBACK_URL }}
|
||||||
|
DEPLOY_SERVICE_TOKEN: ${{ secrets.DEPLOY_SERVICE_TOKEN }}
|
||||||
|
DEPLOYMENT_ID: ${{ env.DEPLOYMENT_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 获取当前 commit SHA (Gitea Actions 使用 GITHUB_SHA)
|
||||||
|
COMMIT_SHA="${GITHUB_SHA}"
|
||||||
|
|
||||||
|
curl -X POST "$DEPLOY_SERVICE_CALLBACK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $DEPLOY_SERVICE_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"deploymentId": "'"${DEPLOYMENT_ID}"'",
|
||||||
|
"status": "deployed",
|
||||||
|
"commitSha": "'"${COMMIT_SHA}"'",
|
||||||
|
"cfDeploymentId": "",
|
||||||
|
"errorMessage": null
|
||||||
|
}'
|
||||||
|
|
||||||
|
- name: Notify Deploy Service (failure)
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
DEPLOY_SERVICE_CALLBACK_URL: ${{ secrets.DEPLOY_SERVICE_CALLBACK_URL }}
|
||||||
|
DEPLOY_SERVICE_TOKEN: ${{ secrets.DEPLOY_SERVICE_TOKEN }}
|
||||||
|
DEPLOYMENT_ID: ${{ env.DEPLOYMENT_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 获取当前 commit SHA
|
||||||
|
COMMIT_SHA="${GITHUB_SHA}"
|
||||||
|
|
||||||
|
curl -X POST "$DEPLOY_SERVICE_CALLBACK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $DEPLOY_SERVICE_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"deploymentId": "'"${DEPLOYMENT_ID}"'",
|
||||||
|
"status": "failed",
|
||||||
|
"commitSha": "'"${COMMIT_SHA}"'",
|
||||||
|
"cfDeploymentId": "",
|
||||||
|
"errorMessage": "see Gitea Actions logs"
|
||||||
|
}'
|
||||||
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
|
.claude
|
||||||
|
docs
|
||||||
|
.pnpm-store
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
116
AGENT.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Client SDK Usage Guide
|
||||||
|
|
||||||
|
This project uses an auto-generated SDK powered by `@hey-api/openapi-ts` to interact with the PayloadCMS multi-tenant backend.
|
||||||
|
|
||||||
|
## SDK Structure
|
||||||
|
|
||||||
|
- **Core Methods**: `src/clientsdk/sdk.gen.ts` (Contains classes like `Posts`, `Categories`, `Pages`, `Media`)
|
||||||
|
- **Type Definitions**: `src/clientsdk/types.gen.ts` (Contains interfaces like `Post`, `Category`, `Media`)
|
||||||
|
- **Query Serializer**: `src/clientsdk/querySerializer.ts` (Handles nested object serialization for PayloadCMS queries)
|
||||||
|
- **Client Factory**: `src/clientsdk/client/index.ts` (Use `createClient` to initialize a client instance)
|
||||||
|
|
||||||
|
## 1. Initialization
|
||||||
|
|
||||||
|
You must initialize the client with the correct `baseUrl`, `querySerializer`, and tenant headers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createClient } from '../clientsdk/client';
|
||||||
|
import { customQuerySerializer } from '../clientsdk/querySerializer';
|
||||||
|
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config';
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
querySerializer: customQuerySerializer, // CRITICAL: Required for nested where queries
|
||||||
|
headers: {
|
||||||
|
'X-Tenant-Slug': TENANT_SLUG,
|
||||||
|
'X-API-Key': TENANT_API_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Common Operations
|
||||||
|
|
||||||
|
### Fetching a List (with Sorting & Limits)
|
||||||
|
```typescript
|
||||||
|
import { Posts } from '../clientsdk/sdk.gen';
|
||||||
|
|
||||||
|
const response = await Posts.listPosts({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
limit: 10,
|
||||||
|
sort: '-createdAt', // Prefix with '-' for descending
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Access the data
|
||||||
|
const posts = response.data?.docs || [];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetching a Single Document by Slug (Filtering)
|
||||||
|
PayloadCMS uses a specific `where` query syntax. The `customQuerySerializer` handles the translation to `where[slug][equals]=my-slug`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await Posts.listPosts({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
equals: 'my-article-slug',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = response.data?.docs?.[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtering by Category Slug
|
||||||
|
```typescript
|
||||||
|
const response = await Posts.listPosts({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
where: {
|
||||||
|
'categories.slug': {
|
||||||
|
equals: 'news',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Data Patterns
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
- **Categories**: In this project, categories are a **many-to-many** relationship. Always treat `post.categories` as an array.
|
||||||
|
- Correct: `post.categories?.[0]?.title`
|
||||||
|
- Incorrect: `post.category.title`
|
||||||
|
- **Media**: Images (like `heroImage`) are objects containing `url`, `alt`, and `sizes`.
|
||||||
|
- Example: `<img src={post.heroImage?.url} alt={post.heroImage?.alt} />`
|
||||||
|
|
||||||
|
### Rich Text (Lexical)
|
||||||
|
PayloadCMS provides Lexical rich text. We typically use `post.content_html` which is pre-rendered to HTML on the server.
|
||||||
|
- **React**: `<div dangerouslySetInnerHTML={{ __html: post.content_html }} />`
|
||||||
|
- **Vue**: `<div v-html="post.content_html" />`
|
||||||
|
- **Astro**: `<div set:html={post.content_html} />`
|
||||||
|
|
||||||
|
## 4. TypeScript Usage
|
||||||
|
|
||||||
|
Always use the generated types for better DX and safety.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Post, Category, Media } from '../clientsdk/types.gen';
|
||||||
|
|
||||||
|
function formatPost(post: Post) {
|
||||||
|
return {
|
||||||
|
title: post.title,
|
||||||
|
date: new Date(post.createdAt).toLocaleDateString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Troubleshooting
|
||||||
|
|
||||||
|
- **CORS Issues**: Ensure `API_URL` in `config.ts` matches the backend port (default 3000).
|
||||||
|
- **Empty Results**: Check if the `X-Tenant-Slug` header matches the slug assigned to your content in the CMS admin panel.
|
||||||
|
- **Nested Query Error**: If you see "Deeply-nested arrays/objects aren’t supported", verify that you are passing the `customQuerySerializer` to the `createClient` options.
|
||||||
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
eslint.config.js
Normal 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']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
BIN
hero-building.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="示例集团 - 集科技研发、金融服务、产业投资于一体的综合性企业集团" />
|
||||||
|
<meta name="keywords" content="示例集团,金融服务,科技研发,产业投资,企业咨询" />
|
||||||
|
<meta name="author" content="示例集团" />
|
||||||
|
<title>示例集团 - 稳健前行 · 携手共赢</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "react-template",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"framer-motion": "^12.23.26",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
41
public/favicon.svg
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 背景渐变 -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#14385d;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#215d9b;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accentGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 圆角矩形背景 -->
|
||||||
|
<rect width="64" height="64" rx="12" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<!-- 建筑图标 -->
|
||||||
|
<g transform="translate(16, 14)">
|
||||||
|
<!-- 主建筑 -->
|
||||||
|
<rect x="4" y="12" width="24" height="24" rx="2" fill="white" opacity="0.95"/>
|
||||||
|
|
||||||
|
<!-- 窗户网格 -->
|
||||||
|
<rect x="8" y="16" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
<rect x="14" y="16" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
<rect x="20" y="16" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<rect x="8" y="22" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
<rect x="14" y="22" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
<rect x="20" y="22" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<rect x="8" y="28" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
<rect x="14" y="28" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
<rect x="20" y="28" width="4" height="4" rx="1" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<!-- 门 -->
|
||||||
|
<rect x="12" y="32" width="8" height="4" rx="1" fill="url(#accentGradient)"/>
|
||||||
|
|
||||||
|
<!-- 顶部装饰 -->
|
||||||
|
<path d="M 2 12 L 16 2 L 30 12" stroke="url(#accentGradient)" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/images/about-office.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
12
public/images/hero-bg.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="600" viewBox="0 0 1920 600">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="heroGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1e3a8a"/>
|
||||||
|
<stop offset="100%" style="stop-color:#1e293b"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1920" height="600" fill="url(#heroGrad)"/>
|
||||||
|
<circle cx="1600" cy="150" r="200" fill="#d4af37" opacity="0.1"/>
|
||||||
|
<circle cx="200" cy="500" r="150" fill="#d4af37" opacity="0.08"/>
|
||||||
|
<rect x="100" y="200" width="3" height="200" fill="#d4af37" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
BIN
public/images/hero-building.jpg
Normal file
|
After Width: | Height: | Size: 472 KiB |
5
public/images/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60" viewBox="0 0 200 60">
|
||||||
|
<rect width="200" height="60" fill="#1e3a8a"/>
|
||||||
|
<text x="100" y="38" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="#d4af37" text-anchor="middle">示例集团</text>
|
||||||
|
<text x="100" y="52" font-family="Arial, sans-serif" font-size="10" fill="#ffffff" text-anchor="middle" opacity="0.8">CHENGYU GROUP</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 428 B |
BIN
public/images/news-award.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/news-company.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/images/news-tech.jpg
Normal file
|
After Width: | Height: | Size: 178 KiB |
7
public/images/placeholder-honor.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#f1f5f9"/>
|
||||||
|
<rect x="50" y="50" width="300" height="200" rx="8" fill="#1e3a8a" opacity="0.1"/>
|
||||||
|
<path d="M150 150 L200 100 L250 150 L200 200 Z" fill="#d4af37" opacity="0.6"/>
|
||||||
|
<rect x="100" y="220" width="200" height="8" rx="2" fill="#1e3a8a" opacity="0.3"/>
|
||||||
|
<rect x="120" y="235" width="160" height="6" rx="2" fill="#94a3b8" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 481 B |
8
public/images/placeholder-news.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250">
|
||||||
|
<rect width="400" height="250" fill="#e2e8f0"/>
|
||||||
|
<rect x="140" y="75" width="120" height="100" rx="4" fill="#1e3a8a" opacity="0.15"/>
|
||||||
|
<rect x="160" y="95" width="80" height="60" rx="2" fill="#d4af37" opacity="0.2"/>
|
||||||
|
<rect x="150" y="180" width="100" height="12" rx="2" fill="#1e3a8a" opacity="0.4"/>
|
||||||
|
<rect x="150" y="200" width="150" height="8" rx="2" fill="#94a3b8" opacity="0.5"/>
|
||||||
|
<rect x="150" y="215" width="120" height="8" rx="2" fill="#94a3b8" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 572 B |
9
public/images/placeholder-services.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="400" viewBox="0 0 800 400">
|
||||||
|
<rect width="800" height="400" fill="#f8fafc"/>
|
||||||
|
<rect x="50" y="50" width="700" height="300" rx="8" fill="#1e3a8a" opacity="0.1"/>
|
||||||
|
<rect x="100" y="100" width="200" height="120" rx="4" fill="#1e3a8a" opacity="0.2"/>
|
||||||
|
<rect x="320" y="100" width="200" height="120" rx="4" fill="#d4af37" opacity="0.2"/>
|
||||||
|
<rect x="540" y="100" width="160" height="120" rx="4" fill="#1e3a8a" opacity="0.15"/>
|
||||||
|
<rect x="100" y="240" width="600" height="8" rx="2" fill="#d4af37" opacity="0.3"/>
|
||||||
|
<rect x="100" y="260" width="500" height="8" rx="2" fill="#94a3b8" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 662 B |
7
public/images/placeholder-team.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#f1f5f9"/>
|
||||||
|
<rect x="150" y="80" width="100" height="100" rx="50" fill="#1e3a8a" opacity="0.2"/>
|
||||||
|
<circle cx="200" cy="130" r="40" fill="#1e3a8a" opacity="0.3"/>
|
||||||
|
<rect x="160" y="180" width="80" height="10" rx="2" fill="#d4af37" opacity="0.5"/>
|
||||||
|
<rect x="170" y="200" width="60" height="6" rx="2" fill="#94a3b8" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 467 B |
1
public/vite.svg
Normal 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 |
80
src/App.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Home } from './pages/Home'
|
||||||
|
import { About } from './pages/About'
|
||||||
|
import { Services } from './pages/Services'
|
||||||
|
import { News } from './pages/News'
|
||||||
|
import { Contact } from './pages/Contact'
|
||||||
|
|
||||||
|
// 页面切换动画配置
|
||||||
|
const pageVariants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -20,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTransition = {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面包装组件 - 添加动画效果
|
||||||
|
const PageWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={pageVariants}
|
||||||
|
transition={pageTransition}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 404 页面组件
|
||||||
|
const NotFound = () => (
|
||||||
|
<PageWrapper>
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-9xl font-bold text-primary">404</h1>
|
||||||
|
<p className="text-2xl font-medium text-gray-600 mt-4">页面不存在</p>
|
||||||
|
<p className="text-gray-500 mt-2">抱歉,您访问的页面不存在或已被移除</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-block mt-8 px-8 py-3 bg-primary text-white rounded-lg hover:bg-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageWrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PageWrapper><Home /></PageWrapper>} />
|
||||||
|
<Route path="/about" element={<PageWrapper><About /></PageWrapper>} />
|
||||||
|
<Route path="/services" element={<PageWrapper><Services /></PageWrapper>} />
|
||||||
|
<Route path="/news" element={<PageWrapper><News /></PageWrapper>} />
|
||||||
|
<Route path="/contact" element={<PageWrapper><Contact /></PageWrapper>} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</AnimatePresence>
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
16
src/clientsdk/client.gen.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||||
|
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:3000' }));
|
||||||
301
src/clientsdk/client/client.gen.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||||
|
import type { HttpMethod } from '../core/types.gen';
|
||||||
|
import { getValidRequestBody } from '../core/utils.gen';
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
Config,
|
||||||
|
RequestOptions,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
} from './types.gen';
|
||||||
|
import {
|
||||||
|
buildUrl,
|
||||||
|
createConfig,
|
||||||
|
createInterceptors,
|
||||||
|
getParseAs,
|
||||||
|
mergeConfigs,
|
||||||
|
mergeHeaders,
|
||||||
|
setAuthParams,
|
||||||
|
} from './utils.gen';
|
||||||
|
|
||||||
|
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||||
|
body?: any;
|
||||||
|
headers: ReturnType<typeof mergeHeaders>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createClient = (config: Config = {}): Client => {
|
||||||
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
|
const setConfig = (config: Config): Config => {
|
||||||
|
_config = mergeConfigs(_config, config);
|
||||||
|
return getConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const interceptors = createInterceptors<
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
unknown,
|
||||||
|
ResolvedRequestOptions
|
||||||
|
>();
|
||||||
|
|
||||||
|
const beforeRequest = async (options: RequestOptions) => {
|
||||||
|
const opts = {
|
||||||
|
..._config,
|
||||||
|
...options,
|
||||||
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
|
serializedBody: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.security) {
|
||||||
|
await setAuthParams({
|
||||||
|
...opts,
|
||||||
|
security: opts.security,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.requestValidator) {
|
||||||
|
await opts.requestValidator(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
|
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
|
if (opts.body === undefined || opts.serializedBody === '') {
|
||||||
|
opts.headers.delete('Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildUrl(opts);
|
||||||
|
|
||||||
|
return { opts, url };
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: Client['request'] = async (options) => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
const requestInit: ReqInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...opts,
|
||||||
|
body: getValidRequestBody(opts),
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = new Request(url, requestInit);
|
||||||
|
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = opts.fetch!;
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await _fetch(request);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||||
|
let finalError = error;
|
||||||
|
|
||||||
|
for (const fn of interceptors.error.fns) {
|
||||||
|
if (fn) {
|
||||||
|
finalError = (await fn(
|
||||||
|
error,
|
||||||
|
undefined as any,
|
||||||
|
request,
|
||||||
|
opts,
|
||||||
|
)) as unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalError = finalError || ({} as unknown);
|
||||||
|
|
||||||
|
if (opts.throwOnError) {
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return error response
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
error: finalError,
|
||||||
|
request,
|
||||||
|
response: undefined as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fn of interceptors.response.fns) {
|
||||||
|
if (fn) {
|
||||||
|
response = await fn(response, request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const parseAs =
|
||||||
|
(opts.parseAs === 'auto'
|
||||||
|
? getParseAs(response.headers.get('Content-Type'))
|
||||||
|
: opts.parseAs) ?? 'json';
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.status === 204 ||
|
||||||
|
response.headers.get('Content-Length') === '0'
|
||||||
|
) {
|
||||||
|
let emptyData: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'text':
|
||||||
|
emptyData = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'formData':
|
||||||
|
emptyData = new FormData();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
emptyData = response.body;
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
default:
|
||||||
|
emptyData = {};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? emptyData
|
||||||
|
: {
|
||||||
|
data: emptyData,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'formData':
|
||||||
|
case 'json':
|
||||||
|
case 'text':
|
||||||
|
data = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? response.body
|
||||||
|
: {
|
||||||
|
data: response.body,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseAs === 'json') {
|
||||||
|
if (opts.responseValidator) {
|
||||||
|
await opts.responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.responseTransformer) {
|
||||||
|
data = await opts.responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? data
|
||||||
|
: {
|
||||||
|
data,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textError = await response.text();
|
||||||
|
let jsonError: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonError = JSON.parse(textError);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = jsonError ?? textError;
|
||||||
|
let finalError = error;
|
||||||
|
|
||||||
|
for (const fn of interceptors.error.fns) {
|
||||||
|
if (fn) {
|
||||||
|
finalError = (await fn(error, response, request, opts)) as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalError = finalError || ({} as string);
|
||||||
|
|
||||||
|
if (opts.throwOnError) {
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we probably want to return error and improve types
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
error: finalError,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMethodFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
request({ ...options, method });
|
||||||
|
|
||||||
|
const makeSseFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
return createSseClient({
|
||||||
|
...opts,
|
||||||
|
body: opts.body as BodyInit | null | undefined,
|
||||||
|
headers: opts.headers as unknown as Record<string, string>,
|
||||||
|
method,
|
||||||
|
onRequest: async (url, init) => {
|
||||||
|
let request = new Request(url, init);
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl,
|
||||||
|
connect: makeMethodFn('CONNECT'),
|
||||||
|
delete: makeMethodFn('DELETE'),
|
||||||
|
get: makeMethodFn('GET'),
|
||||||
|
getConfig,
|
||||||
|
head: makeMethodFn('HEAD'),
|
||||||
|
interceptors,
|
||||||
|
options: makeMethodFn('OPTIONS'),
|
||||||
|
patch: makeMethodFn('PATCH'),
|
||||||
|
post: makeMethodFn('POST'),
|
||||||
|
put: makeMethodFn('PUT'),
|
||||||
|
request,
|
||||||
|
setConfig,
|
||||||
|
sse: {
|
||||||
|
connect: makeSseFn('CONNECT'),
|
||||||
|
delete: makeSseFn('DELETE'),
|
||||||
|
get: makeSseFn('GET'),
|
||||||
|
head: makeSseFn('HEAD'),
|
||||||
|
options: makeSseFn('OPTIONS'),
|
||||||
|
patch: makeSseFn('PATCH'),
|
||||||
|
post: makeSseFn('POST'),
|
||||||
|
put: makeSseFn('PUT'),
|
||||||
|
trace: makeSseFn('TRACE'),
|
||||||
|
},
|
||||||
|
trace: makeMethodFn('TRACE'),
|
||||||
|
} as Client;
|
||||||
|
};
|
||||||
25
src/clientsdk/client/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type { Auth } from '../core/auth.gen';
|
||||||
|
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
export {
|
||||||
|
formDataBodySerializer,
|
||||||
|
jsonBodySerializer,
|
||||||
|
urlSearchParamsBodySerializer,
|
||||||
|
} from '../core/bodySerializer.gen';
|
||||||
|
export { buildClientParams } from '../core/params.gen';
|
||||||
|
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||||
|
export { createClient } from './client.gen';
|
||||||
|
export type {
|
||||||
|
Client,
|
||||||
|
ClientOptions,
|
||||||
|
Config,
|
||||||
|
CreateClientConfig,
|
||||||
|
Options,
|
||||||
|
RequestOptions,
|
||||||
|
RequestResult,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
ResponseStyle,
|
||||||
|
TDataShape,
|
||||||
|
} from './types.gen';
|
||||||
|
export { createConfig, mergeHeaders } from './utils.gen';
|
||||||
241
src/clientsdk/client/types.gen.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth } from '../core/auth.gen';
|
||||||
|
import type {
|
||||||
|
ServerSentEventsOptions,
|
||||||
|
ServerSentEventsResult,
|
||||||
|
} from '../core/serverSentEvents.gen';
|
||||||
|
import type {
|
||||||
|
Client as CoreClient,
|
||||||
|
Config as CoreConfig,
|
||||||
|
} from '../core/types.gen';
|
||||||
|
import type { Middleware } from './utils.gen';
|
||||||
|
|
||||||
|
export type ResponseStyle = 'data' | 'fields';
|
||||||
|
|
||||||
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
|
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||||
|
CoreConfig {
|
||||||
|
/**
|
||||||
|
* Base URL for all requests made by this client.
|
||||||
|
*/
|
||||||
|
baseUrl?: T['baseUrl'];
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||||
|
* options won't have any effect.
|
||||||
|
*
|
||||||
|
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||||
|
*/
|
||||||
|
next?: never;
|
||||||
|
/**
|
||||||
|
* Return the response data parsed in a specified format. By default, `auto`
|
||||||
|
* will infer the appropriate method from the `Content-Type` response header.
|
||||||
|
* You can override this behavior with any of the {@link Body} methods.
|
||||||
|
* Select `stream` if you don't want to parse response data at all.
|
||||||
|
*
|
||||||
|
* @default 'auto'
|
||||||
|
*/
|
||||||
|
parseAs?:
|
||||||
|
| 'arrayBuffer'
|
||||||
|
| 'auto'
|
||||||
|
| 'blob'
|
||||||
|
| 'formData'
|
||||||
|
| 'json'
|
||||||
|
| 'stream'
|
||||||
|
| 'text';
|
||||||
|
/**
|
||||||
|
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||||
|
*
|
||||||
|
* @default 'fields'
|
||||||
|
*/
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
/**
|
||||||
|
* Throw an error instead of returning it in the response?
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
throwOnError?: T['throwOnError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions<
|
||||||
|
TData = unknown,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
> extends Config<{
|
||||||
|
responseStyle: TResponseStyle;
|
||||||
|
throwOnError: ThrowOnError;
|
||||||
|
}>,
|
||||||
|
Pick<
|
||||||
|
ServerSentEventsOptions<TData>,
|
||||||
|
| 'onSseError'
|
||||||
|
| 'onSseEvent'
|
||||||
|
| 'sseDefaultRetryDelay'
|
||||||
|
| 'sseMaxRetryAttempts'
|
||||||
|
| 'sseMaxRetryDelay'
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Any body that you want to add to your request.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
|
*/
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Security mechanism(s) to use for the request.
|
||||||
|
*/
|
||||||
|
security?: ReadonlyArray<Auth>;
|
||||||
|
url: Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedRequestOptions<
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||||
|
serializedBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestResult<
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = ThrowOnError extends true
|
||||||
|
? Promise<
|
||||||
|
TResponseStyle extends 'data'
|
||||||
|
? TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData
|
||||||
|
: {
|
||||||
|
data: TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData;
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
: Promise<
|
||||||
|
TResponseStyle extends 'data'
|
||||||
|
?
|
||||||
|
| (TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData)
|
||||||
|
| undefined
|
||||||
|
: (
|
||||||
|
| {
|
||||||
|
data: TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData;
|
||||||
|
error: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data: undefined;
|
||||||
|
error: TError extends Record<string, unknown>
|
||||||
|
? TError[keyof TError]
|
||||||
|
: TError;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
throwOnError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MethodFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
type SseFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
|
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||||
|
|
||||||
|
type RequestFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||||
|
Pick<
|
||||||
|
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||||
|
'method'
|
||||||
|
>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
type BuildUrlFn = <
|
||||||
|
TData extends {
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
options: TData & Options<TData>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type Client = CoreClient<
|
||||||
|
RequestFn,
|
||||||
|
Config,
|
||||||
|
MethodFn,
|
||||||
|
BuildUrlFn,
|
||||||
|
SseFn
|
||||||
|
> & {
|
||||||
|
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
|
override?: Config<ClientOptions & T>,
|
||||||
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export interface TDataShape {
|
||||||
|
body?: unknown;
|
||||||
|
headers?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
query?: unknown;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
|
export type Options<
|
||||||
|
TData extends TDataShape = TDataShape,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponse = unknown,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = OmitKeys<
|
||||||
|
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||||
|
'body' | 'path' | 'query' | 'url'
|
||||||
|
> &
|
||||||
|
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||||
332
src/clientsdk/client/utils.gen.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { getAuthToken } from '../core/auth.gen';
|
||||||
|
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from '../core/pathSerializer.gen';
|
||||||
|
import { getUrl } from '../core/utils.gen';
|
||||||
|
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||||
|
|
||||||
|
export const createQuerySerializer = <T = unknown>({
|
||||||
|
parameters = {},
|
||||||
|
...args
|
||||||
|
}: QuerySerializerOptions = {}) => {
|
||||||
|
const querySerializer = (queryParams: T) => {
|
||||||
|
const search: string[] = [];
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const name in queryParams) {
|
||||||
|
const value = queryParams[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parameters[name] || args;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const serializedArray = serializeArrayParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'form',
|
||||||
|
value,
|
||||||
|
...options.array,
|
||||||
|
});
|
||||||
|
if (serializedArray) search.push(serializedArray);
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
const serializedObject = serializeObjectParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'deepObject',
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
...options.object,
|
||||||
|
});
|
||||||
|
if (serializedObject) search.push(serializedObject);
|
||||||
|
} else {
|
||||||
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
});
|
||||||
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return search.join('&');
|
||||||
|
};
|
||||||
|
return querySerializer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers parseAs value from provided Content-Type header.
|
||||||
|
*/
|
||||||
|
export const getParseAs = (
|
||||||
|
contentType: string | null,
|
||||||
|
): Exclude<Config['parseAs'], 'auto'> => {
|
||||||
|
if (!contentType) {
|
||||||
|
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||||
|
// which is effectively the same as the 'stream' option.
|
||||||
|
return 'stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanContent = contentType.split(';')[0]?.trim();
|
||||||
|
|
||||||
|
if (!cleanContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleanContent.startsWith('application/json') ||
|
||||||
|
cleanContent.endsWith('+json')
|
||||||
|
) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent === 'multipart/form-data') {
|
||||||
|
return 'formData';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||||
|
cleanContent.startsWith(type),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'blob';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent.startsWith('text/')) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForExistence = (
|
||||||
|
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Headers;
|
||||||
|
},
|
||||||
|
name?: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.headers.has(name) ||
|
||||||
|
options.query?.[name] ||
|
||||||
|
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAuthParams = async ({
|
||||||
|
security,
|
||||||
|
...options
|
||||||
|
}: Pick<Required<RequestOptions>, 'security'> &
|
||||||
|
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Headers;
|
||||||
|
}) => {
|
||||||
|
for (const auth of security) {
|
||||||
|
if (checkForExistence(options, auth.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = auth.name ?? 'Authorization';
|
||||||
|
|
||||||
|
switch (auth.in) {
|
||||||
|
case 'query':
|
||||||
|
if (!options.query) {
|
||||||
|
options.query = {};
|
||||||
|
}
|
||||||
|
options.query[name] = token;
|
||||||
|
break;
|
||||||
|
case 'cookie':
|
||||||
|
options.headers.append('Cookie', `${name}=${token}`);
|
||||||
|
break;
|
||||||
|
case 'header':
|
||||||
|
default:
|
||||||
|
options.headers.set(name, token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
|
getUrl({
|
||||||
|
baseUrl: options.baseUrl as string,
|
||||||
|
path: options.path,
|
||||||
|
query: options.query,
|
||||||
|
querySerializer:
|
||||||
|
typeof options.querySerializer === 'function'
|
||||||
|
? options.querySerializer
|
||||||
|
: createQuerySerializer(options.querySerializer),
|
||||||
|
url: options.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
|
const config = { ...a, ...b };
|
||||||
|
if (config.baseUrl?.endsWith('/')) {
|
||||||
|
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||||
|
const entries: Array<[string, string]> = [];
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
entries.push([key, value]);
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeHeaders = (
|
||||||
|
...headers: Array<Required<Config>['headers'] | undefined>
|
||||||
|
): Headers => {
|
||||||
|
const mergedHeaders = new Headers();
|
||||||
|
for (const header of headers) {
|
||||||
|
if (!header) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator =
|
||||||
|
header instanceof Headers
|
||||||
|
? headersEntries(header)
|
||||||
|
: Object.entries(header);
|
||||||
|
|
||||||
|
for (const [key, value] of iterator) {
|
||||||
|
if (value === null) {
|
||||||
|
mergedHeaders.delete(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
mergedHeaders.append(key, v as string);
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
// assume object headers are meant to be JSON stringified, i.e. their
|
||||||
|
// content value in OpenAPI specification is 'application/json'
|
||||||
|
mergedHeaders.set(
|
||||||
|
key,
|
||||||
|
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||||
|
error: Err,
|
||||||
|
response: Res,
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Err | Promise<Err>;
|
||||||
|
|
||||||
|
type ReqInterceptor<Req, Options> = (
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Req | Promise<Req>;
|
||||||
|
|
||||||
|
type ResInterceptor<Res, Req, Options> = (
|
||||||
|
response: Res,
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Res | Promise<Res>;
|
||||||
|
|
||||||
|
class Interceptors<Interceptor> {
|
||||||
|
fns: Array<Interceptor | null> = [];
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.fns = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
eject(id: number | Interceptor): void {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
if (this.fns[index]) {
|
||||||
|
this.fns[index] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exists(id: number | Interceptor): boolean {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
return Boolean(this.fns[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInterceptorIndex(id: number | Interceptor): number {
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
return this.fns[id] ? id : -1;
|
||||||
|
}
|
||||||
|
return this.fns.indexOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: number | Interceptor,
|
||||||
|
fn: Interceptor,
|
||||||
|
): number | Interceptor | false {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
if (this.fns[index]) {
|
||||||
|
this.fns[index] = fn;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(fn: Interceptor): number {
|
||||||
|
this.fns.push(fn);
|
||||||
|
return this.fns.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Middleware<Req, Res, Err, Options> {
|
||||||
|
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||||
|
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||||
|
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
Err,
|
||||||
|
Options
|
||||||
|
> => ({
|
||||||
|
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||||
|
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||||
|
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultQuerySerializer = createQuerySerializer({
|
||||||
|
allowReserved: false,
|
||||||
|
array: {
|
||||||
|
explode: true,
|
||||||
|
style: 'form',
|
||||||
|
},
|
||||||
|
object: {
|
||||||
|
explode: true,
|
||||||
|
style: 'deepObject',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
|
...jsonBodySerializer,
|
||||||
|
headers: defaultHeaders,
|
||||||
|
parseAs: 'auto',
|
||||||
|
querySerializer: defaultQuerySerializer,
|
||||||
|
...override,
|
||||||
|
});
|
||||||
42
src/clientsdk/core/auth.gen.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
/**
|
||||||
|
* Which part of the request do we use to send the auth?
|
||||||
|
*
|
||||||
|
* @default 'header'
|
||||||
|
*/
|
||||||
|
in?: 'header' | 'query' | 'cookie';
|
||||||
|
/**
|
||||||
|
* Header or query parameter name.
|
||||||
|
*
|
||||||
|
* @default 'Authorization'
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
scheme?: 'basic' | 'bearer';
|
||||||
|
type: 'apiKey' | 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthToken = async (
|
||||||
|
auth: Auth,
|
||||||
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const token =
|
||||||
|
typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'bearer') {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'basic') {
|
||||||
|
return `Basic ${btoa(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
100
src/clientsdk/core/bodySerializer.gen.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ArrayStyle,
|
||||||
|
ObjectStyle,
|
||||||
|
SerializerOptions,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export type BodySerializer = (body: any) => any;
|
||||||
|
|
||||||
|
type QuerySerializerOptionsObject = {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||||
|
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||||
|
/**
|
||||||
|
* Per-parameter serialization overrides. When provided, these settings
|
||||||
|
* override the global array/object settings for specific parameter names.
|
||||||
|
*/
|
||||||
|
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeFormDataPair = (
|
||||||
|
data: FormData,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void => {
|
||||||
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
|
data.append(key, value);
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
data.append(key, value.toISOString());
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeUrlSearchParamsPair = (
|
||||||
|
data: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formDataBodySerializer = {
|
||||||
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
|
body: T,
|
||||||
|
): FormData => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeFormDataPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonBodySerializer = {
|
||||||
|
bodySerializer: <T>(body: T): string =>
|
||||||
|
JSON.stringify(body, (_key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const urlSearchParamsBodySerializer = {
|
||||||
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
|
body: T,
|
||||||
|
): string => {
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.toString();
|
||||||
|
},
|
||||||
|
};
|
||||||
176
src/clientsdk/core/params.gen.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||||
|
|
||||||
|
export type Field =
|
||||||
|
| {
|
||||||
|
in: Exclude<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If omitted, we use the same value as `key`.
|
||||||
|
*/
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in: Extract<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Key isn't required for bodies.
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||||
|
*/
|
||||||
|
map: Slot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Fields {
|
||||||
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
|
args?: ReadonlyArray<Field>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
|
$body_: 'body',
|
||||||
|
$headers_: 'headers',
|
||||||
|
$path_: 'path',
|
||||||
|
$query_: 'query',
|
||||||
|
};
|
||||||
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
|
type KeyMap = Map<
|
||||||
|
string,
|
||||||
|
| {
|
||||||
|
in: Slot;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in?: never;
|
||||||
|
map: Slot;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of fields) {
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
map.set(config.key, {
|
||||||
|
in: config.in,
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if ('key' in config) {
|
||||||
|
map.set(config.key, {
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
} else if (config.args) {
|
||||||
|
buildKeyMap(config.args, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
body: unknown;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripEmptySlots = (params: Params) => {
|
||||||
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
|
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||||
|
delete params[slot as Slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildClientParams = (
|
||||||
|
args: ReadonlyArray<unknown>,
|
||||||
|
fields: FieldsConfig,
|
||||||
|
) => {
|
||||||
|
const params: Params = {
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
path: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
|
for (const [index, arg] of args.entries()) {
|
||||||
|
if (fields[index]) {
|
||||||
|
config = fields[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
const field = map.get(config.key)!;
|
||||||
|
const name = field.map || config.key;
|
||||||
|
if (field.in) {
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.body = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
|
const field = map.get(key);
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
if (field.in) {
|
||||||
|
const name = field.map || key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
|
} else {
|
||||||
|
params[field.map] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const extra = extraPrefixes.find(([prefix]) =>
|
||||||
|
key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extra) {
|
||||||
|
const [prefix, slot] = extra;
|
||||||
|
(params[slot] as Record<string, unknown>)[
|
||||||
|
key.slice(prefix.length)
|
||||||
|
] = value;
|
||||||
|
} else if ('allowExtra' in config && config.allowExtra) {
|
||||||
|
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||||
|
if (allowed) {
|
||||||
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripEmptySlots(params);
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
181
src/clientsdk/core/pathSerializer.gen.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
interface SerializeOptions<T>
|
||||||
|
extends SerializePrimitiveOptions,
|
||||||
|
SerializerOptions<T> {}
|
||||||
|
|
||||||
|
interface SerializePrimitiveOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializerOptions<T> {
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
explode: boolean;
|
||||||
|
style: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||||
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
|
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||||
|
export type ObjectStyle = 'form' | 'deepObject';
|
||||||
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return ',';
|
||||||
|
case 'pipeDelimited':
|
||||||
|
return '|';
|
||||||
|
case 'spaceDelimited':
|
||||||
|
return '%20';
|
||||||
|
default:
|
||||||
|
return ',';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeArrayParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
|
value: unknown[];
|
||||||
|
}) => {
|
||||||
|
if (!explode) {
|
||||||
|
const joinedValues = (
|
||||||
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
|
).join(separatorArrayNoExplode(style));
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
case 'simple':
|
||||||
|
return joinedValues;
|
||||||
|
default:
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorArrayExplode(style);
|
||||||
|
const joinedValues = value
|
||||||
|
.map((v) => {
|
||||||
|
if (style === 'label' || style === 'simple') {
|
||||||
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: v as string,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializePrimitiveParam = ({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}: SerializePrimitiveParam) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
throw new Error(
|
||||||
|
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeObjectParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
valueOnly,
|
||||||
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
|
value: Record<string, unknown> | Date;
|
||||||
|
valueOnly?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style !== 'deepObject' && !explode) {
|
||||||
|
let values: string[] = [];
|
||||||
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
|
values = [
|
||||||
|
...values,
|
||||||
|
key,
|
||||||
|
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const joinedValues = values.join(',');
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
default:
|
||||||
|
return joinedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorObjectExplode(style);
|
||||||
|
const joinedValues = Object.entries(value)
|
||||||
|
.map(([key, v]) =>
|
||||||
|
serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||||
|
value: v as string,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
|
};
|
||||||
136
src/clientsdk/core/queryKeySerializer.gen.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||||
|
*/
|
||||||
|
export type JsonValue =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||||
|
*/
|
||||||
|
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||||
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
typeof value === 'function' ||
|
||||||
|
typeof value === 'symbol'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely stringifies a value and parses it back into a JsonValue.
|
||||||
|
*/
|
||||||
|
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||||
|
if (json === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(json) as JsonValue;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects plain objects (including objects with a null prototype).
|
||||||
|
*/
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prototype = Object.getPrototypeOf(value as object);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||||
|
*/
|
||||||
|
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
|
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
|
const result: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const existing = result[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
result[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
(existing as string[]).push(value);
|
||||||
|
} else {
|
||||||
|
result[key] = [existing, value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||||
|
*/
|
||||||
|
export const serializeQueryKeyValue = (
|
||||||
|
value: unknown,
|
||||||
|
): JsonValue | undefined => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof value === 'string' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
typeof value === 'function' ||
|
||||||
|
typeof value === 'symbol'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return stringifyToJsonValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof URLSearchParams !== 'undefined' &&
|
||||||
|
value instanceof URLSearchParams
|
||||||
|
) {
|
||||||
|
return serializeSearchParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
return stringifyToJsonValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
266
src/clientsdk/core/serverSentEvents.gen.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Config } from './types.gen';
|
||||||
|
|
||||||
|
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||||
|
RequestInit,
|
||||||
|
'method'
|
||||||
|
> &
|
||||||
|
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Implementing clients can call request interceptors inside this hook.
|
||||||
|
*/
|
||||||
|
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||||
|
/**
|
||||||
|
* Callback invoked when a network or parsing error occurs during streaming.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param error The error that occurred.
|
||||||
|
*/
|
||||||
|
onSseError?: (error: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Callback invoked when an event is streamed from the server.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param event Event streamed from the server.
|
||||||
|
* @returns Nothing (void).
|
||||||
|
*/
|
||||||
|
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||||
|
serializedBody?: RequestInit['body'];
|
||||||
|
/**
|
||||||
|
* Default retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 3000
|
||||||
|
*/
|
||||||
|
sseDefaultRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Maximum number of retry attempts before giving up.
|
||||||
|
*/
|
||||||
|
sseMaxRetryAttempts?: number;
|
||||||
|
/**
|
||||||
|
* Maximum retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* Applies only when exponential backoff is used.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 30000
|
||||||
|
*/
|
||||||
|
sseMaxRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Optional sleep function for retry backoff.
|
||||||
|
*
|
||||||
|
* Defaults to using `setTimeout`.
|
||||||
|
*/
|
||||||
|
sseSleepFn?: (ms: number) => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StreamEvent<TData = unknown> {
|
||||||
|
data: TData;
|
||||||
|
event?: string;
|
||||||
|
id?: string;
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerSentEventsResult<
|
||||||
|
TData = unknown,
|
||||||
|
TReturn = void,
|
||||||
|
TNext = unknown,
|
||||||
|
> = {
|
||||||
|
stream: AsyncGenerator<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
|
TReturn,
|
||||||
|
TNext
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSseClient = <TData = unknown>({
|
||||||
|
onRequest,
|
||||||
|
onSseError,
|
||||||
|
onSseEvent,
|
||||||
|
responseTransformer,
|
||||||
|
responseValidator,
|
||||||
|
sseDefaultRetryDelay,
|
||||||
|
sseMaxRetryAttempts,
|
||||||
|
sseMaxRetryDelay,
|
||||||
|
sseSleepFn,
|
||||||
|
url,
|
||||||
|
...options
|
||||||
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||||
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
|
const sleep =
|
||||||
|
sseSleepFn ??
|
||||||
|
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
|
const createStream = async function* () {
|
||||||
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
|
let attempt = 0;
|
||||||
|
const signal = options.signal ?? new AbortController().signal;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
const headers =
|
||||||
|
options.headers instanceof Headers
|
||||||
|
? options.headers
|
||||||
|
: new Headers(options.headers as Record<string, string> | undefined);
|
||||||
|
|
||||||
|
if (lastEventId !== undefined) {
|
||||||
|
headers.set('Last-Event-ID', lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...options,
|
||||||
|
body: options.serializedBody,
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
};
|
||||||
|
let request = new Request(url, requestInit);
|
||||||
|
if (onRequest) {
|
||||||
|
request = await onRequest(url, requestInit);
|
||||||
|
}
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await _fetch(request);
|
||||||
|
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`SSE failed: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('No body in SSE response');
|
||||||
|
|
||||||
|
const reader = response.body
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.getReader();
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
try {
|
||||||
|
reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += value;
|
||||||
|
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||||
|
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
const chunks = buffer.split('\n\n');
|
||||||
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
const dataLines: Array<string> = [];
|
||||||
|
let eventName: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||||
|
} else if (line.startsWith('event:')) {
|
||||||
|
eventName = line.replace(/^event:\s*/, '');
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
lastEventId = line.replace(/^id:\s*/, '');
|
||||||
|
} else if (line.startsWith('retry:')) {
|
||||||
|
const parsed = Number.parseInt(
|
||||||
|
line.replace(/^retry:\s*/, ''),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
retryDelay = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
let parsedJson = false;
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
const rawData = dataLines.join('\n');
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
parsedJson = true;
|
||||||
|
} catch {
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedJson) {
|
||||||
|
if (responseValidator) {
|
||||||
|
await responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseTransformer) {
|
||||||
|
data = await responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSseEvent?.({
|
||||||
|
data,
|
||||||
|
event: eventName,
|
||||||
|
id: lastEventId,
|
||||||
|
retry: retryDelay,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
yield data as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // exit loop on normal completion
|
||||||
|
} catch (error) {
|
||||||
|
// connection failed or aborted; retry after delay
|
||||||
|
onSseError?.(error);
|
||||||
|
|
||||||
|
if (
|
||||||
|
sseMaxRetryAttempts !== undefined &&
|
||||||
|
attempt >= sseMaxRetryAttempts
|
||||||
|
) {
|
||||||
|
break; // stop after firing error
|
||||||
|
}
|
||||||
|
|
||||||
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
|
const backoff = Math.min(
|
||||||
|
retryDelay * 2 ** (attempt - 1),
|
||||||
|
sseMaxRetryDelay ?? 30000,
|
||||||
|
);
|
||||||
|
await sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = createStream();
|
||||||
|
|
||||||
|
return { stream };
|
||||||
|
};
|
||||||
118
src/clientsdk/core/types.gen.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth, AuthToken } from './auth.gen';
|
||||||
|
import type {
|
||||||
|
BodySerializer,
|
||||||
|
QuerySerializer,
|
||||||
|
QuerySerializerOptions,
|
||||||
|
} from './bodySerializer.gen';
|
||||||
|
|
||||||
|
export type HttpMethod =
|
||||||
|
| 'connect'
|
||||||
|
| 'delete'
|
||||||
|
| 'get'
|
||||||
|
| 'head'
|
||||||
|
| 'options'
|
||||||
|
| 'patch'
|
||||||
|
| 'post'
|
||||||
|
| 'put'
|
||||||
|
| 'trace';
|
||||||
|
|
||||||
|
export type Client<
|
||||||
|
RequestFn = never,
|
||||||
|
Config = unknown,
|
||||||
|
MethodFn = never,
|
||||||
|
BuildUrlFn = never,
|
||||||
|
SseFn = never,
|
||||||
|
> = {
|
||||||
|
/**
|
||||||
|
* Returns the final request URL.
|
||||||
|
*/
|
||||||
|
buildUrl: BuildUrlFn;
|
||||||
|
getConfig: () => Config;
|
||||||
|
request: RequestFn;
|
||||||
|
setConfig: (config: Config) => Config;
|
||||||
|
} & {
|
||||||
|
[K in HttpMethod]: MethodFn;
|
||||||
|
} & ([SseFn] extends [never]
|
||||||
|
? { sse?: never }
|
||||||
|
: { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/**
|
||||||
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
|
* added to the request payload as defined by its `security` array.
|
||||||
|
*/
|
||||||
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
|
/**
|
||||||
|
* A function for serializing request body parameter. By default,
|
||||||
|
* {@link JSON.stringify()} will be used.
|
||||||
|
*/
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| RequestInit['headers']
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| (string | number | boolean)[]
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* The request method.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
|
*/
|
||||||
|
method?: Uppercase<HttpMethod>;
|
||||||
|
/**
|
||||||
|
* A function for serializing request query parameters. By default, arrays
|
||||||
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
|
* style, and reserved characters are percent-encoded.
|
||||||
|
*
|
||||||
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
|
* API function is used.
|
||||||
|
*
|
||||||
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
|
*/
|
||||||
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
|
/**
|
||||||
|
* A function validating request data. This is useful if you want to ensure
|
||||||
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function transforming response data before it's returned. This is useful
|
||||||
|
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||||
|
*/
|
||||||
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function validating response data. This is useful if you want to ensure
|
||||||
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
|
* the transformers and returned to the user.
|
||||||
|
*/
|
||||||
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
|
? true
|
||||||
|
: [T] extends [never | undefined]
|
||||||
|
? [undefined] extends [T]
|
||||||
|
? false
|
||||||
|
: true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||||
|
? never
|
||||||
|
: K]: T[K];
|
||||||
|
};
|
||||||
143
src/clientsdk/core/utils.gen.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
type ArraySeparatorStyle,
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export interface PathSerializer {
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
|
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
|
let url = _url;
|
||||||
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
|
if (matches) {
|
||||||
|
for (const match of matches) {
|
||||||
|
let explode = false;
|
||||||
|
let name = match.substring(1, match.length - 1);
|
||||||
|
let style: ArraySeparatorStyle = 'simple';
|
||||||
|
|
||||||
|
if (name.endsWith('*')) {
|
||||||
|
explode = true;
|
||||||
|
name = name.substring(0, name.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith('.')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'label';
|
||||||
|
} else if (name.startsWith(';')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'matrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = path[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeArrayParam({ explode, name, style, value }),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeObjectParam({
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
valueOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'matrix') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
`;${serializePrimitiveParam({
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceValue = encodeURIComponent(
|
||||||
|
style === 'label' ? `.${value as string}` : (value as string),
|
||||||
|
);
|
||||||
|
url = url.replace(match, replaceValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUrl = ({
|
||||||
|
baseUrl,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
querySerializer,
|
||||||
|
url: _url,
|
||||||
|
}: {
|
||||||
|
baseUrl?: string;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
querySerializer: QuerySerializer;
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||||
|
let url = (baseUrl ?? '') + pathUrl;
|
||||||
|
if (path) {
|
||||||
|
url = defaultPathSerializer({ path, url });
|
||||||
|
}
|
||||||
|
let search = query ? querySerializer(query) : '';
|
||||||
|
if (search.startsWith('?')) {
|
||||||
|
search = search.substring(1);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
url += `?${search}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getValidRequestBody(options: {
|
||||||
|
body?: unknown;
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
serializedBody?: unknown;
|
||||||
|
}) {
|
||||||
|
const hasBody = options.body !== undefined;
|
||||||
|
const isSerializedBody = hasBody && options.bodySerializer;
|
||||||
|
|
||||||
|
if (isSerializedBody) {
|
||||||
|
if ('serializedBody' in options) {
|
||||||
|
const hasSerializedBody =
|
||||||
|
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||||
|
|
||||||
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||||
|
return options.body !== '' ? options.body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain/text body
|
||||||
|
if (hasBody) {
|
||||||
|
return options.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no body was provided
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
4
src/clientsdk/index.ts
Normal file
28
src/clientsdk/querySerializer.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export const customQuerySerializer = (queryParams: any) => {
|
||||||
|
const search: string[] = [];
|
||||||
|
|
||||||
|
const serialize = (name: string, value: any) => {
|
||||||
|
if (value === undefined || value === null) return;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v, i) => {
|
||||||
|
serialize(`${name}[${i}]`, v);
|
||||||
|
});
|
||||||
|
} else if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
|
||||||
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
|
serialize(`${name}[${key}]`, v);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const val = value instanceof Date ? value.toISOString() : String(value);
|
||||||
|
search.push(`${encodeURIComponent(name)}=${encodeURIComponent(val)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const key in queryParams) {
|
||||||
|
serialize(key, queryParams[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return search.join('&');
|
||||||
|
};
|
||||||
1098
src/clientsdk/sdk.gen.ts
Normal file
6179
src/clientsdk/types.gen.ts
Normal file
250
src/components/Footer.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Clock,
|
||||||
|
MessageCircle,
|
||||||
|
Share2,
|
||||||
|
Linkedin as LinkedinIcon,
|
||||||
|
ArrowRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
COMPANY_INFO,
|
||||||
|
FOOTER_LINKS,
|
||||||
|
SOCIAL_MEDIA,
|
||||||
|
} from '../lib/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer 组件 - 企业官网页脚
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 动画变体配置
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
|
Wechat: MessageCircle,
|
||||||
|
Weibo: Share2,
|
||||||
|
Linkedin: LinkedinIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系方式项组件
|
||||||
|
*/
|
||||||
|
const ContactItem: React.FC<{
|
||||||
|
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}> = ({ icon: Icon, title, content }) => (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon size={18} className="text-accent mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-0.5">{title}</p>
|
||||||
|
<p className="text-sm text-gray-200">{content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 链接列组件
|
||||||
|
*/
|
||||||
|
const LinkColumn: React.FC<{
|
||||||
|
title: string;
|
||||||
|
links: Array<{ label: string; path: string }>;
|
||||||
|
}> = ({ title, links }) => (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<li key={link.path}>
|
||||||
|
<Link
|
||||||
|
to={link.path}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-300 hover:text-accent transition-colors duration-200 group"
|
||||||
|
>
|
||||||
|
<ArrowRight
|
||||||
|
size={14}
|
||||||
|
className="opacity-0 -ml-4 group-hover:opacity-100 group-hover:ml-0 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
<span className="group-hover:translate-x-1 transition-transform duration-200">
|
||||||
|
{link.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer 组件
|
||||||
|
*/
|
||||||
|
export const Footer: React.FC = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-primary-dark text-white" role="contentinfo">
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 lg:gap-12"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
{/* 企业信息 */}
|
||||||
|
<motion.div className="lg:col-span-2 space-y-6" variants={itemVariants}>
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-3 group" aria-label="返回首页">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-accent text-primary-dark">
|
||||||
|
<Building2 size={28} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xl font-bold text-white group-hover:text-accent transition-colors">
|
||||||
|
示例集团
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-400">Chengyu Group</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 企业简介 */}
|
||||||
|
<p className="text-sm text-gray-300 leading-relaxed max-w-sm">
|
||||||
|
{COMPANY_INFO.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 联系方式 */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<ContactItem
|
||||||
|
icon={MapPin}
|
||||||
|
title="总部地址"
|
||||||
|
content={COMPANY_INFO.headquarters}
|
||||||
|
/>
|
||||||
|
<ContactItem
|
||||||
|
icon={Phone}
|
||||||
|
title="服务热线"
|
||||||
|
content={COMPANY_INFO.phone}
|
||||||
|
/>
|
||||||
|
<ContactItem
|
||||||
|
icon={Mail}
|
||||||
|
title="商务邮箱"
|
||||||
|
content={COMPANY_INFO.email}
|
||||||
|
/>
|
||||||
|
<ContactItem
|
||||||
|
icon={Clock}
|
||||||
|
title="工作时间"
|
||||||
|
content={COMPANY_INFO.workingHours}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 产品服务 */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<LinkColumn title="产品服务" links={FOOTER_LINKS.products} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 公司信息 */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<LinkColumn title="关于我们" links={FOOTER_LINKS.company} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 社交媒体 */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">
|
||||||
|
关注我们
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mb-4">
|
||||||
|
了解更多企业动态
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{SOCIAL_MEDIA.map((social) => {
|
||||||
|
const Icon = iconMap[social.icon] || MessageCircle;
|
||||||
|
return (
|
||||||
|
<motion.a
|
||||||
|
key={social.id}
|
||||||
|
href={social.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-white/10 rounded-lg text-sm text-gray-300 hover:bg-accent hover:text-primary-dark transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
aria-label={social.label}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span className="hidden sm:inline">{social.label}</span>
|
||||||
|
</motion.a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部版权栏 */}
|
||||||
|
<div className="border-t border-white/10">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
{/* 版权信息 */}
|
||||||
|
<motion.p
|
||||||
|
className="text-sm text-gray-400"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
© {currentYear} {COMPANY_INFO.fullName} 版权所有
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* 备案和链接 */}
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-wrap items-center justify-center gap-4 text-sm text-gray-400"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<span>京ICP备XXXXXXXX号</span>
|
||||||
|
<span className="hidden sm:inline">|</span>
|
||||||
|
<Link
|
||||||
|
to="/privacy"
|
||||||
|
className="hover:text-accent transition-colors duration-200"
|
||||||
|
>
|
||||||
|
隐私政策
|
||||||
|
</Link>
|
||||||
|
<span className="hidden sm:inline">|</span>
|
||||||
|
<Link
|
||||||
|
to="/terms"
|
||||||
|
className="hover:text-accent transition-colors duration-200"
|
||||||
|
>
|
||||||
|
使用条款
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
296
src/components/Header.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Menu, X, Building2, Phone, Mail, Search, ChevronDown } from 'lucide-react';
|
||||||
|
import { NAVIGATION_MENU } from '../lib/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header 组件 - 企业官网导航栏
|
||||||
|
*/
|
||||||
|
export const Header: React.FC = () => {
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 监听滚动事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 关闭移动端菜单 - 使用回调避免直接 setState
|
||||||
|
const closeMobileMenu = useCallback(() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeMobileMenu();
|
||||||
|
}, [location.pathname, closeMobileMenu]);
|
||||||
|
|
||||||
|
// 检查当前路径是否为活动状态
|
||||||
|
const isActive = (path: string): boolean => {
|
||||||
|
if (path === '/') {
|
||||||
|
return location.pathname === '/';
|
||||||
|
}
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 顶部信息栏 - 玻璃效果 */}
|
||||||
|
<motion.div
|
||||||
|
className={`fixed top-0 left-0 right-0 z-50 text-white text-xs transition-all duration-300 border-b border-white/10 ${
|
||||||
|
isScrolled ? 'h-0 opacity-0 overflow-hidden' : 'h-10'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(to right, rgba(20, 56, 93, 0.85), rgba(33, 93, 155, 0.85))',
|
||||||
|
backdropFilter: 'blur(12px) saturate(150%)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px) saturate(150%)',
|
||||||
|
}}
|
||||||
|
initial={{ y: -40 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Phone size={14} />
|
||||||
|
<span>400-123-4567</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<Mail size={14} />
|
||||||
|
<span>contact@chengyu.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-4">
|
||||||
|
<span className="text-white/80">欢迎来到示例集团官网</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white/60">|</span>
|
||||||
|
<button className="hover:text-accent transition-colors">简体中文</button>
|
||||||
|
<span className="text-white/60">/</span>
|
||||||
|
<button className="hover:text-accent transition-colors">English</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 主导航栏 */}
|
||||||
|
<motion.header
|
||||||
|
className={`fixed left-0 right-0 z-50 transition-all duration-300 ${
|
||||||
|
isScrolled
|
||||||
|
? 'top-0 bg-white/80 backdrop-blur-xl backdrop-saturate-150 shadow-lg border-b border-white/20'
|
||||||
|
: 'top-10 bg-white/60 backdrop-blur-lg backdrop-saturate-120 shadow-md border-b border-white/30'
|
||||||
|
}`}
|
||||||
|
initial={{ y: -100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
style={{
|
||||||
|
background: isScrolled
|
||||||
|
? 'rgba(255, 255, 255, 0.8)'
|
||||||
|
: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
backdropFilter: isScrolled
|
||||||
|
? 'blur(20px) saturate(150%)'
|
||||||
|
: 'blur(16px) saturate(120%)',
|
||||||
|
WebkitBackdropFilter: isScrolled
|
||||||
|
? 'blur(20px) saturate(150%)'
|
||||||
|
: 'blur(16px) saturate(120%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-[80px]">
|
||||||
|
{/* Logo 区域 - 增强版 */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-3 group relative"
|
||||||
|
aria-label="返回首页"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-primary to-primary-light text-white shadow-lg"
|
||||||
|
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<Building2 size={26} />
|
||||||
|
{/* 光晕效果 */}
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-primary to-primary-light opacity-20 blur-xl group-hover:opacity-40 transition-opacity" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<span className="text-xl font-bold bg-gradient-to-r from-primary-dark to-primary bg-clip-text text-transparent group-hover:from-primary group-hover:to-primary-light transition-all">
|
||||||
|
示例集团
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-500 -mt-1 font-medium tracking-wider">CHENGYU GROUP</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 桌面端导航菜单 - 增强版 */}
|
||||||
|
<nav
|
||||||
|
className="hidden lg:flex items-center gap-1"
|
||||||
|
role="navigation"
|
||||||
|
aria-label="主导航"
|
||||||
|
>
|
||||||
|
{NAVIGATION_MENU.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={item.path}
|
||||||
|
className={`group relative px-5 py-3 text-sm font-medium rounded-xl transition-all duration-300 flex items-center gap-2 ${
|
||||||
|
isActive(item.path)
|
||||||
|
? 'text-primary bg-primary/5'
|
||||||
|
: 'text-gray-700 hover:text-primary hover:bg-primary/5'
|
||||||
|
}`}
|
||||||
|
aria-current={isActive(item.path) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span className="relative z-10">{item.label}</span>
|
||||||
|
|
||||||
|
{/* 悬停背景效果 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-xl bg-gradient-to-r from-primary/10 to-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
|
initial={false}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 桌面端操作区域 */}
|
||||||
|
<div className="hidden lg:flex items-center gap-3">
|
||||||
|
{/* 搜索按钮 */}
|
||||||
|
<motion.button
|
||||||
|
className="p-2.5 rounded-lg text-gray-600 hover:text-primary hover:bg-primary/5 transition-all"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
aria-label="搜索"
|
||||||
|
>
|
||||||
|
<Search size={20} />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* 联系电话按钮 */}
|
||||||
|
<motion.a
|
||||||
|
href="tel:400-123-4567"
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-primary border-2 border-primary rounded-lg hover:bg-primary hover:text-white transition-all"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Phone size={16} />
|
||||||
|
<span className="hidden xl:inline">400-123-4567</span>
|
||||||
|
</motion.a>
|
||||||
|
|
||||||
|
{/* CTA 按钮 - 渐变风格 */}
|
||||||
|
<motion.button
|
||||||
|
className="relative px-6 py-2.5 text-sm font-medium text-white rounded-lg overflow-hidden group"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-primary to-primary-light" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-accent to-primary opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
<span className="relative z-10 flex items-center gap-2">
|
||||||
|
立即咨询
|
||||||
|
<motion.span
|
||||||
|
animate={{ x: [0, 3, 0] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端菜单按钮 */}
|
||||||
|
<motion.button
|
||||||
|
className="lg:hidden p-2.5 rounded-xl text-gray-600 hover:bg-primary/5 hover:text-primary transition-all"
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
aria-label={isMobileMenuOpen ? '关闭菜单' : '打开菜单'}
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isMobileMenuOpen ? 90 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</motion.div>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端菜单 - 玻璃效果版 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<motion.nav
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="lg:hidden overflow-hidden shadow-xl border-b border-white/20"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
backdropFilter: 'blur(20px) saturate(150%)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px) saturate(150%)',
|
||||||
|
}}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="移动端导航"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-6 space-y-3">
|
||||||
|
{NAVIGATION_MENU.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={item.path}
|
||||||
|
className={`block px-5 py-3.5 rounded-xl text-base font-medium transition-all duration-200 ${
|
||||||
|
isActive(item.path)
|
||||||
|
? 'bg-gradient-to-r from-primary/15 to-accent/15 text-primary shadow-sm backdrop-blur-sm'
|
||||||
|
: 'text-gray-700 hover:bg-white/30 active:scale-95 backdrop-blur-sm'
|
||||||
|
}`}
|
||||||
|
aria-current={isActive(item.path) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 移动端联系方式 */}
|
||||||
|
<motion.div
|
||||||
|
className="pt-4 mt-2 border-t border-white/20 space-y-3"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="tel:400-123-4567"
|
||||||
|
className="flex items-center gap-3 px-5 py-3 rounded-xl text-gray-700 hover:bg-white/30 transition-all backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<Phone size={18} className="text-primary" />
|
||||||
|
<span className="text-sm font-medium">400-123-4567</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
className="w-full px-5 py-3.5 text-base font-medium text-white rounded-xl bg-gradient-to-r from-primary to-primary-light shadow-lg shadow-primary/30 backdrop-blur-sm"
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
立即咨询
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.nav>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
296
src/components/Hero.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { motion, useInView, useAnimation } from 'framer-motion';
|
||||||
|
import { ArrowDown, TrendingUp, Users, Building2, Award } from 'lucide-react';
|
||||||
|
import { COMPANY_INFO, COMPANY_STATS } from '../lib/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数字递增动画组件
|
||||||
|
*/
|
||||||
|
const CountUpNumber: React.FC<{
|
||||||
|
value: string;
|
||||||
|
suffix?: string;
|
||||||
|
duration?: number;
|
||||||
|
}> = ({ value, suffix = '', duration = 2 }) => {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const isInView = useInView(ref, { once: true });
|
||||||
|
const controls = useAnimation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInView && ref.current) {
|
||||||
|
const numValue = parseInt(value.replace(/[^0-9]/g, ''));
|
||||||
|
const hasPlus = value.includes('+');
|
||||||
|
const hasYi = value.includes('亿');
|
||||||
|
|
||||||
|
controls.start({
|
||||||
|
opacity: [0, 1],
|
||||||
|
scale: [0.5, 1.2, 1],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数字递增动画
|
||||||
|
let current = 0;
|
||||||
|
const increment = numValue / (duration * 60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
current += increment;
|
||||||
|
if (current >= numValue) {
|
||||||
|
current = numValue;
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
let display = Math.floor(current);
|
||||||
|
if (hasYi) {
|
||||||
|
display = Math.floor(current);
|
||||||
|
}
|
||||||
|
ref.current.textContent = display.toLocaleString() + (hasPlus ? '+' : '') + (hasYi ? '亿' : suffix);
|
||||||
|
}, 1000 / 60);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [isInView, value, suffix, duration, controls]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
ref={ref}
|
||||||
|
className="text-3xl md:text-4xl font-bold text-accent"
|
||||||
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
|
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计数据项组件
|
||||||
|
*/
|
||||||
|
const StatItem: React.FC<{
|
||||||
|
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
suffix?: string;
|
||||||
|
}> = ({ icon: Icon, label, value, suffix }) => (
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center gap-4 p-4 bg-white/10 rounded-xl backdrop-blur-sm"
|
||||||
|
whileHover={{ scale: 1.02, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-accent/20">
|
||||||
|
<Icon size={24} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<CountUpNumber value={value} suffix={suffix} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-300">{label}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero 组件 - 首页大图区域
|
||||||
|
*/
|
||||||
|
export const Hero: React.FC = () => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const statsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 滚动到指定区域
|
||||||
|
const scrollToSection = (sectionId: string) => {
|
||||||
|
const element = document.getElementById(sectionId);
|
||||||
|
if (element) {
|
||||||
|
const offset = 80;
|
||||||
|
const elementPosition = element.getBoundingClientRect().top;
|
||||||
|
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative min-h-screen flex items-center overflow-hidden"
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
{/* 背景层 */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
{/* 图片背景 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{ backgroundImage: 'url(/images/hero-building.jpg)' }}
|
||||||
|
/>
|
||||||
|
{/* 渐变遮罩 - 确保文字可读性 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary-dark/90 via-primary/80 to-primary/70" />
|
||||||
|
{/* 装饰性图案 */}
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<div className="absolute top-0 right-0 w-[800px] h-[800px] rounded-full bg-white/5 blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] rounded-full bg-accent/10 blur-3xl translate-y-1/2 -translate-x-1/2" />
|
||||||
|
</div>
|
||||||
|
{/* 网格图案 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-5"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '50px 50px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
|
{/* 左侧内容 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{/* 企业标语 */}
|
||||||
|
<motion.h1
|
||||||
|
className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white leading-tight"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{COMPANY_INFO.slogan}
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* 企业名称 */}
|
||||||
|
<motion.p
|
||||||
|
className="mt-6 text-2xl sm:text-3xl font-semibold text-accent"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{COMPANY_INFO.fullName}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* 企业简介 */}
|
||||||
|
<motion.p
|
||||||
|
className="mt-6 text-lg text-gray-200 leading-relaxed max-w-xl"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.6, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{COMPANY_INFO.description}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* CTA 按钮组 */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-8 flex flex-wrap gap-4"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => scrollToSection('about')}
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-primary-dark font-semibold rounded-lg shadow-lg shadow-accent/25 hover:shadow-xl hover:shadow-accent/30 transition-all duration-300"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
了解更多
|
||||||
|
<ArrowDown size={20} />
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => scrollToSection('contact')}
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 border-2 border-white/30 text-white font-semibold rounded-lg hover:bg-white/10 transition-all duration-300"
|
||||||
|
whileHover={{ scale: 1.05, borderColor: 'rgba(255,255,255,0.5)' }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
联系我们
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 右侧统计数据 */}
|
||||||
|
<motion.div
|
||||||
|
ref={statsRef}
|
||||||
|
className="space-y-4"
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.8, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<motion.h2
|
||||||
|
className="text-xl font-semibold text-white mb-6"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
>
|
||||||
|
核心数据
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* 成立年限 */}
|
||||||
|
<StatItem
|
||||||
|
icon={Award}
|
||||||
|
label={COMPANY_STATS[0].label}
|
||||||
|
value={COMPANY_STATS[0].value}
|
||||||
|
suffix={COMPANY_STATS[0].suffix}
|
||||||
|
/>
|
||||||
|
{/* 员工数量 */}
|
||||||
|
<StatItem
|
||||||
|
icon={Users}
|
||||||
|
label={COMPANY_STATS[1].label}
|
||||||
|
value={COMPANY_STATS[1].value}
|
||||||
|
suffix={COMPANY_STATS[1].suffix}
|
||||||
|
/>
|
||||||
|
{/* 服务客户 */}
|
||||||
|
<StatItem
|
||||||
|
icon={Building2}
|
||||||
|
label={COMPANY_STATS[2].label}
|
||||||
|
value={COMPANY_STATS[2].value}
|
||||||
|
suffix={COMPANY_STATS[2].suffix}
|
||||||
|
/>
|
||||||
|
{/* 管理资产 */}
|
||||||
|
<StatItem
|
||||||
|
icon={TrendingUp}
|
||||||
|
label={COMPANY_STATS[3].label}
|
||||||
|
value={COMPANY_STATS[3].value}
|
||||||
|
suffix={COMPANY_STATS[3].suffix}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 额外说明 */}
|
||||||
|
<motion.p
|
||||||
|
className="mt-6 text-sm text-gray-400 text-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1 }}
|
||||||
|
>
|
||||||
|
数据截至 2025 年 12 月
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部装饰 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-background to-transparent"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1.2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 滚动提示 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, y: [0, 10, 0] }}
|
||||||
|
transition={{ delay: 1.5, duration: 2, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToSection('about')}
|
||||||
|
className="flex flex-col items-center gap-2 text-white/60 hover:text-accent transition-colors duration-300"
|
||||||
|
aria-label="向下滚动"
|
||||||
|
>
|
||||||
|
<span className="text-xs uppercase tracking-wider">Scroll</span>
|
||||||
|
<ArrowDown size={20} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
188
src/components/Home/AboutSection.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Target, Eye, Heart, Award, ArrowRight } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AboutSection 组件 - 关于我们简介区域
|
||||||
|
*/
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
{
|
||||||
|
icon: Heart,
|
||||||
|
title: '诚信为本',
|
||||||
|
description: '坚守诚信底线,建立长期信任关系',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Target,
|
||||||
|
title: '创新驱动',
|
||||||
|
description: '持续创新,保持行业领先优势',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Award,
|
||||||
|
title: '品质至上',
|
||||||
|
description: '追求卓越,提供高品质服务',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AboutSection: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="about"
|
||||||
|
className="py-20 lg:py-28 bg-background"
|
||||||
|
aria-labelledby="about-heading"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="about-heading"
|
||||||
|
className="text-3xl md:text-4xl font-bold text-primary-dark"
|
||||||
|
>
|
||||||
|
关于示例集团
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
|
||||||
|
示例集团成立于 2010 年,是一家集科技研发、金融服务、产业投资于一体的综合性企业集团
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
|
{/* 左侧内容 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-semibold text-primary-dark mb-6">
|
||||||
|
示例集团 · 稳健前行,携手共赢
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-6 text-gray-600 leading-relaxed">
|
||||||
|
<p>
|
||||||
|
示例集团总部位于北京,经过十余年的稳健发展,已形成了以金融服务、科技研发、产业投资为核心的业务体系。集团秉承"诚信、创新、共赢"的核心价值观,致力于为客户创造最大价值。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
我们拥有一支经验丰富、专业高效的管理团队,汇聚了金融、科技、投资等领域的优秀人才。通过持续的业务创新和服务优化,示例集团已成功为超过 1000 家企业客户提供专业服务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 使命与愿景 */}
|
||||||
|
<div className="mt-8 grid sm:grid-cols-2 gap-6">
|
||||||
|
<div className="p-5 bg-white rounded-xl shadow-sm border border-gray-100">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Eye size={20} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-primary-dark">我们的愿景</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
成为具有国际影响力的综合性企业集团
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 bg-white rounded-xl shadow-sm border border-gray-100">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-accent/20 rounded-lg">
|
||||||
|
<Target size={20} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-primary-dark">我们的使命</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
为客户创造价值,为社会贡献力量
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 更多按钮 */}
|
||||||
|
<motion.div className="mt-8" whileHover={{ x: 5 }}>
|
||||||
|
<Link
|
||||||
|
to="/about"
|
||||||
|
className="inline-flex items-center gap-2 text-primary font-medium hover:text-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
了解更多关于我们
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 右侧图片/装饰 */}
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{/* 主图片区域 */}
|
||||||
|
<div className="aspect-[4/3] rounded-2xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src="/images/about-office.jpg"
|
||||||
|
alt="示例集团办公室环境"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* 叠加装饰层 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-primary/20 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 装饰性元素 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute -bottom-6 -left-6 w-32 h-32 bg-accent/20 rounded-2xl -z-10"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
whileInView={{ scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute -top-6 -right-6 w-24 h-24 bg-primary/10 rounded-2xl -z-10"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
whileInView={{ scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.5, duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 核心价值观 */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-20"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-semibold text-center text-primary-dark mb-10">
|
||||||
|
核心价值观
|
||||||
|
</h3>
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={value.title}
|
||||||
|
className="text-center p-8 bg-white rounded-2xl shadow-sm border border-gray-100 hover:shadow-md transition-shadow duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-accent/10 mb-5">
|
||||||
|
<value.icon size={32} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-semibold text-primary-dark mb-3">
|
||||||
|
{value.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-600">{value.description}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutSection;
|
||||||
183
src/components/Home/NewsSection.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Calendar, ArrowRight } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { formatDate } from '../../lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewsSection 组件 - 最新动态区域
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 模拟新闻数据
|
||||||
|
const newsItems = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
category: 'company',
|
||||||
|
title: '示例集团荣获"2025年度优秀企业"称号',
|
||||||
|
excerpt: '在近日举办的年度企业评选活动中,示例集团凭借其卓越的经营业绩和社会责任表现,荣获"2025年度优秀企业"称号。',
|
||||||
|
date: '2025-12-20',
|
||||||
|
image: '/images/news-award.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
category: 'industry',
|
||||||
|
title: '金融科技创新论坛圆满落幕,示例集团分享行业洞察',
|
||||||
|
excerpt: '示例集团受邀参加金融科技创新论坛,与行业专家共同探讨金融科技发展趋势,分享公司在数字化转型方面的实践经验。',
|
||||||
|
date: '2025-12-15',
|
||||||
|
image: '/images/news-tech.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
category: 'achievement',
|
||||||
|
title: '示例集团完成新一轮战略融资,估值突破百亿',
|
||||||
|
excerpt: '示例集团宣布完成新一轮战略融资,本轮融资由知名投资机构领投,估值突破百亿元人民币,标志着公司发展进入新阶段。',
|
||||||
|
date: '2025-12-10',
|
||||||
|
image: '/images/news-company.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 新闻分类映射
|
||||||
|
const categoryMap: Record<string, { label: string; color: string }> = {
|
||||||
|
company: { label: '公司动态', color: 'bg-primary/10 text-primary' },
|
||||||
|
industry: { label: '行业资讯', color: 'bg-accent/20 text-accent-dark' },
|
||||||
|
achievement: { label: '荣誉资质', color: 'bg-green-100 text-green-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻卡片组件
|
||||||
|
*/
|
||||||
|
const NewsCard: React.FC<{
|
||||||
|
news: typeof newsItems[0];
|
||||||
|
index: number;
|
||||||
|
}> = ({ news, index }) => {
|
||||||
|
const category = categoryMap[news.category] || categoryMap.company;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
className="group bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-lg transition-shadow duration-300"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
{/* 图片区域 */}
|
||||||
|
<div className="aspect-[16/9] bg-gradient-to-br from-primary/5 to-primary-light/10 relative overflow-hidden">
|
||||||
|
{news.image ? (
|
||||||
|
<img
|
||||||
|
src={news.image}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-primary/20">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1}
|
||||||
|
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 分类标签 */}
|
||||||
|
<span
|
||||||
|
className={`absolute top-4 left-4 px-3 py-1 text-xs font-medium rounded-full ${category.color}`}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</span>
|
||||||
|
{/* 图片遮罩 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="p-6">
|
||||||
|
{/* 日期 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<time dateTime={news.date}>{formatDate(news.date, 'YYYY年MM月DD日')}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<h3 className="text-lg font-semibold text-primary-dark mb-3 line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{news.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 摘要 */}
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3 mb-4">
|
||||||
|
{news.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 了解更多链接 */}
|
||||||
|
<Link
|
||||||
|
to={`/news/${news.id}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-primary group-hover:text-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
阅读全文
|
||||||
|
<motion.span
|
||||||
|
initial={{ x: 0 }}
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</motion.span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewsSection: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="news"
|
||||||
|
className="py-20 lg:py-28 bg-background"
|
||||||
|
aria-labelledby="news-heading"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-12"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id="news-heading"
|
||||||
|
className="text-3xl md:text-4xl font-bold text-primary-dark"
|
||||||
|
>
|
||||||
|
新闻资讯
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-lg text-gray-600">
|
||||||
|
了解示例集团最新动态
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/news"
|
||||||
|
className="inline-flex items-center gap-2 text-primary font-medium hover:text-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
查看更多新闻
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 新闻卡片网格 */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{newsItems.map((news, index) => (
|
||||||
|
<NewsCard key={news.id} news={news} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewsSection;
|
||||||
135
src/components/Home/ServicesSection.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { TrendingUp, Cpu, Building2, Briefcase, ArrowRight } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { SERVICES } from '../../lib/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServicesSection 组件 - 核心业务展示区域
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 业务卡片组件
|
||||||
|
const ServiceCard: React.FC<{
|
||||||
|
service: typeof SERVICES[0];
|
||||||
|
index: number;
|
||||||
|
}> = ({ service, index }) => {
|
||||||
|
const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
|
TrendingUp,
|
||||||
|
Cpu,
|
||||||
|
Building2,
|
||||||
|
Briefcase,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = iconMap[service.icon] || TrendingUp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="group relative p-8 bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -8, shadow: '0 20px 40px rgba(0,0,0,0.1)' }}
|
||||||
|
>
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary/5 to-accent/5 rounded-full -translate-y-1/2 translate-x-1/2 group-hover:scale-150 transition-transform duration-500" />
|
||||||
|
|
||||||
|
{/* 图标 */}
|
||||||
|
<div className="relative z-10 inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary-light text-white mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<Icon size={32} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<h3 className="relative z-10 text-xl font-semibold text-primary-dark mb-4">
|
||||||
|
{service.title}
|
||||||
|
</h3>
|
||||||
|
<p className="relative z-10 text-gray-600 leading-relaxed mb-6">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 特性列表 */}
|
||||||
|
<ul className="relative z-10 space-y-3 mb-8">
|
||||||
|
{service.features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* 了解更多链接 */}
|
||||||
|
<Link
|
||||||
|
to={`/services/${service.id}`}
|
||||||
|
className="relative z-10 inline-flex items-center gap-2 text-primary font-medium group-hover:text-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
了解更多
|
||||||
|
<motion.span
|
||||||
|
initial={{ x: 0 }}
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</motion.span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 悬停边框效果 */}
|
||||||
|
<div className="absolute inset-0 border-2 border-transparent group-hover:border-accent/30 rounded-2xl transition-colors duration-300" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServicesSection: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="services"
|
||||||
|
className="py-20 lg:py-28 bg-white"
|
||||||
|
aria-labelledby="services-heading"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="services-heading"
|
||||||
|
className="text-3xl md:text-4xl font-bold text-primary-dark"
|
||||||
|
>
|
||||||
|
核心业务
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
|
||||||
|
我们提供全方位的专业服务,帮助客户实现商业目标
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 业务卡片网格 */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||||
|
{SERVICES.map((service, index) => (
|
||||||
|
<ServiceCard key={service.id} service={service} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 查看全部服务 */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-12 text-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/services"
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-light transition-colors shadow-lg shadow-primary/25"
|
||||||
|
>
|
||||||
|
查看全部服务
|
||||||
|
<ArrowRight size={20} />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServicesSection;
|
||||||
41
src/components/PostCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
title: string
|
||||||
|
excerpt: string
|
||||||
|
category?: string
|
||||||
|
date: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PostCard: React.FC<PostCardProps> = ({
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
category,
|
||||||
|
date,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="border border-gray-200 rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer bg-white"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{category && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500">{date}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">{title}</h2>
|
||||||
|
<p className="text-gray-600 line-clamp-3">{excerpt}</p>
|
||||||
|
<div className="mt-4 text-blue-600 text-sm font-medium flex items-center gap-1">
|
||||||
|
阅读更多
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/components/PostCardSkeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const PostCardSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-6 shadow-sm bg-white animate-pulse">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-16 h-5 bg-gray-200 rounded-full"></div>
|
||||||
|
<div className="w-20 h-4 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const TENANT_SLUG = "zitadel-example"
|
||||||
|
export const TENANT_API_KEY = "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j"
|
||||||
|
export const API_URL = "http://localhost:3000"
|
||||||
5
src/env.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const ENV = {
|
||||||
|
VITE_TENANT_SLUG: "zitadel-example",
|
||||||
|
VITE_TENANT_API_KEY: "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j",
|
||||||
|
VITE_API_URL: "http://localhost:3000/api",
|
||||||
|
}
|
||||||
18
src/hooks/usePageTitle.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Hook - 设置页面标题
|
||||||
|
* @param title 页面标题
|
||||||
|
* @param suffix 标题后缀,默认为 "示例集团"
|
||||||
|
*/
|
||||||
|
export const usePageTitle = (title: string, suffix: string = '示例集团') => {
|
||||||
|
useEffect(() => {
|
||||||
|
const prevTitle = document.title;
|
||||||
|
document.title = title ? `${title} - ${suffix}` : suffix;
|
||||||
|
|
||||||
|
// 清理函数:组件卸载时恢复默认标题
|
||||||
|
return () => {
|
||||||
|
document.title = prevTitle;
|
||||||
|
};
|
||||||
|
}, [title, suffix]);
|
||||||
|
};
|
||||||
45
src/index.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #1e3a8a;
|
||||||
|
--color-primary-light: #2563eb;
|
||||||
|
--color-primary-dark: #1e293b;
|
||||||
|
--color-accent: #d4af37;
|
||||||
|
--color-accent-light: #e5c158;
|
||||||
|
--color-accent-dark: #b8960c;
|
||||||
|
--color-background: #f8fafc;
|
||||||
|
--color-background-light: #ffffff;
|
||||||
|
--color-background-dark: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-background-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
}
|
||||||
177
src/lib/constants.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 示例集团企业官网 - 常量定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 企业基本信息
|
||||||
|
export const COMPANY_INFO = {
|
||||||
|
name: '示例集团',
|
||||||
|
nameEn: 'Chengyu Group',
|
||||||
|
slogan: '稳健前行,携手共赢',
|
||||||
|
description: '示例集团成立于2010年,是一家集科技研发、金融服务、产业投资于一体的综合性企业集团。秉承"诚信、创新、共赢"的经营理念,致力于为客户提供高品质的产品和服务。',
|
||||||
|
fullName: '示例集团有限公司',
|
||||||
|
registrationNumber: '91110000XXXXXXXX',
|
||||||
|
established: '2010年',
|
||||||
|
headquarters: '北京市朝阳区建国路88号',
|
||||||
|
phone: '400-888-8888',
|
||||||
|
email: 'contact@chengyu-group.com',
|
||||||
|
workingHours: '周一至周五 9:00-18:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导航菜单配置
|
||||||
|
export const NAVIGATION_MENU = [
|
||||||
|
{ id: 'home', label: '首页', path: '/' },
|
||||||
|
{ id: 'about', label: '关于我们', path: '/about' },
|
||||||
|
{ id: 'services', label: '产品服务', path: '/services' },
|
||||||
|
{ id: 'news', label: '新闻资讯', path: '/news' },
|
||||||
|
{ id: 'contact', label: '联系我们', path: '/contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 底部导航链接
|
||||||
|
export const FOOTER_LINKS = {
|
||||||
|
products: [
|
||||||
|
{ label: '金融服务', path: '/services/finance' },
|
||||||
|
{ label: '科技研发', path: '/services/tech' },
|
||||||
|
{ label: '产业投资', path: '/services/investment' },
|
||||||
|
{ label: '咨询服务', path: '/services/consulting' },
|
||||||
|
],
|
||||||
|
company: [
|
||||||
|
{ label: '关于我们', path: '/about' },
|
||||||
|
{ label: '新闻资讯', path: '/news' },
|
||||||
|
{ label: '招贤纳士', path: '/careers' },
|
||||||
|
{ label: '联系我们', path: '/contact' },
|
||||||
|
],
|
||||||
|
legal: [
|
||||||
|
{ label: '隐私政策', path: '/privacy' },
|
||||||
|
{ label: '使用条款', path: '/terms' },
|
||||||
|
{ label: '免责声明', path: '/disclaimer' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 社交媒体链接
|
||||||
|
export const SOCIAL_MEDIA = [
|
||||||
|
{
|
||||||
|
id: 'wechat',
|
||||||
|
label: '微信公众号',
|
||||||
|
icon: 'Wechat',
|
||||||
|
url: 'https://weixin.qq.com',
|
||||||
|
description: '示例集团官方微信公众号',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weibo',
|
||||||
|
label: '官方微博',
|
||||||
|
icon: 'Weibo',
|
||||||
|
url: 'https://weibo.com',
|
||||||
|
description: '示例集团官方微博账号',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linkedin',
|
||||||
|
label: 'LinkedIn',
|
||||||
|
icon: 'Linkedin',
|
||||||
|
url: 'https://linkedin.com/company/chengyu-group',
|
||||||
|
description: '示例集团 LinkedIn 主页',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 服务项目配置
|
||||||
|
export const SERVICES = [
|
||||||
|
{
|
||||||
|
id: 'finance',
|
||||||
|
title: '金融服务',
|
||||||
|
description: '提供专业的财富管理、投资顾问、资产配置等金融服务,为客户创造稳健收益。',
|
||||||
|
icon: 'TrendingUp',
|
||||||
|
features: ['财富管理', '投资顾问', '资产配置', '风险管理'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tech',
|
||||||
|
title: '科技研发',
|
||||||
|
description: '聚焦人工智能、大数据、云计算等前沿技术,为企业提供数字化转型解决方案。',
|
||||||
|
icon: 'Cpu',
|
||||||
|
features: ['人工智能', '大数据分析', '云计算服务', '数字化转型'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'investment',
|
||||||
|
title: '产业投资',
|
||||||
|
description: '专注于新兴产业投资机会,通过战略投资推动产业升级和价值创造。',
|
||||||
|
icon: 'Building2',
|
||||||
|
features: ['战略投资', '产业并购', '创业孵化', '退出管理'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consulting',
|
||||||
|
title: '咨询服务',
|
||||||
|
description: '为企业提供战略规划、运营优化、风险管理等专业咨询服务。',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
features: ['战略规划', '运营优化', '风险管理', '组织变革'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 新闻分类
|
||||||
|
export const NEWS_CATEGORIES = [
|
||||||
|
{ id: 'all', label: '全部' },
|
||||||
|
{ id: 'company', label: '公司动态' },
|
||||||
|
{ id: 'industry', label: '行业资讯' },
|
||||||
|
{ id: 'achievement', label: '荣誉资质' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 首页统计数据
|
||||||
|
export const COMPANY_STATS = [
|
||||||
|
{ id: 'years', label: '成立年限', value: '15', suffix: '年' },
|
||||||
|
{ id: 'employees', label: '员工数量', value: '500', suffix: '+' },
|
||||||
|
{ id: 'clients', label: '服务客户', value: '1000', suffix: '+' },
|
||||||
|
{ id: 'assets', label: '管理资产', value: '500', suffix: '亿' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 页面元信息
|
||||||
|
export const PAGE_META = {
|
||||||
|
home: {
|
||||||
|
title: '示例集团 - 稳健前行,携手共赢',
|
||||||
|
description: '示例集团是一家集科技研发、金融服务、产业投资于一体的综合性企业集团',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: '关于我们 - 示例集团',
|
||||||
|
description: '了解示例集团的发展历程、企业文化和核心价值观',
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
title: '产品服务 - 示例集团',
|
||||||
|
description: '提供金融服务、科技研发、产业投资、咨询管理等专业服务',
|
||||||
|
},
|
||||||
|
news: {
|
||||||
|
title: '新闻资讯 - 示例集团',
|
||||||
|
description: '了解示例集团最新动态、行业资讯和荣誉资质',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: '联系我们 - 示例集团',
|
||||||
|
description: '获取示例集团联系方式,欢迎随时与我们沟通',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 联系方式配置
|
||||||
|
export const CONTACT_INFO = [
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
type: 'address',
|
||||||
|
icon: 'MapPin',
|
||||||
|
title: '总部地址',
|
||||||
|
content: COMPANY_INFO.headquarters,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phone',
|
||||||
|
type: 'phone',
|
||||||
|
icon: 'Phone',
|
||||||
|
title: '服务热线',
|
||||||
|
content: COMPANY_INFO.phone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
type: 'email',
|
||||||
|
icon: 'Mail',
|
||||||
|
title: '商务邮箱',
|
||||||
|
content: COMPANY_INFO.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hours',
|
||||||
|
type: 'text',
|
||||||
|
icon: 'Clock',
|
||||||
|
title: '工作时间',
|
||||||
|
content: COMPANY_INFO.workingHours,
|
||||||
|
},
|
||||||
|
];
|
||||||
273
src/lib/utils.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* 示例集团企业官网 - 工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日期格式化函数
|
||||||
|
* @param dateString - ISO 日期字符串
|
||||||
|
* @param format - 格式化模板,默认为 'YYYY-MM-DD'
|
||||||
|
* @returns 格式化后的日期字符串
|
||||||
|
*/
|
||||||
|
export function formatDate(dateString: string, format: 'YYYY-MM-DD' | 'YYYY年MM月DD日' | 'MM/DD/YYYY' = 'YYYY-MM-DD'): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'YYYY年MM月DD日':
|
||||||
|
return `${year}年${String(month).padStart(2, '0')}月${String(day).padStart(2, '0')}日`;
|
||||||
|
case 'MM/DD/YYYY':
|
||||||
|
return `${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}/${year}`;
|
||||||
|
case 'YYYY-MM-DD':
|
||||||
|
default:
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本截取函数(按字符数)
|
||||||
|
* @param text - 原始文本
|
||||||
|
* @param maxLength - 最大字符数
|
||||||
|
* @param suffix - 截取后添加的后缀,默认为 '...'
|
||||||
|
* @returns 截取后的文本
|
||||||
|
*/
|
||||||
|
export function truncateText(text: string, maxLength: number, suffix: string = '...'): string {
|
||||||
|
if (!text) return '';
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength - suffix.length) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本截取函数(按单词数,适用于英文)
|
||||||
|
* @param text - 原始文本
|
||||||
|
* @param maxWords - 最大单词数
|
||||||
|
* @param suffix - 截取后添加的后缀,默认为 '...'
|
||||||
|
* @returns 截取后的文本
|
||||||
|
*/
|
||||||
|
export function truncateWords(text: string, maxWords: number, suffix: string = '...'): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const words = text.split(/\s+/);
|
||||||
|
if (words.length <= maxWords) return text;
|
||||||
|
return words.slice(0, maxWords).join(' ') + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机ID
|
||||||
|
* @param prefix - ID 前缀,默认为 'id'
|
||||||
|
* @returns 生成的随机 ID
|
||||||
|
*/
|
||||||
|
export function generateId(prefix: string = 'id'): string {
|
||||||
|
return `${prefix}-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟函数
|
||||||
|
* @param ms - 延迟毫秒数
|
||||||
|
* @returns Promise
|
||||||
|
*/
|
||||||
|
export function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到指定元素
|
||||||
|
* @param elementId - 元素 ID
|
||||||
|
* @param offset - 偏移量(像素)
|
||||||
|
*/
|
||||||
|
export function scrollToElement(elementId: string, offset: number = 80): void {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
const elementPosition = element.getBoundingClientRect().top;
|
||||||
|
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否在客户端环境
|
||||||
|
* @returns 是否在浏览器环境中
|
||||||
|
*/
|
||||||
|
export function isClient(): boolean {
|
||||||
|
return typeof window !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 URL 查询参数
|
||||||
|
* @param name - 参数名
|
||||||
|
* @returns 参数值或 null
|
||||||
|
*/
|
||||||
|
export function getQueryParam(name: string): string | null {
|
||||||
|
if (!isClient()) return null;
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数字格式化函数
|
||||||
|
* @param num - 数字
|
||||||
|
* @param locale - 地区设置,默认为 'zh-CN'
|
||||||
|
* @returns 格式化后的字符串
|
||||||
|
*/
|
||||||
|
export function formatNumber(num: number, locale: string = 'zh-CN'): string {
|
||||||
|
return new Intl.NumberFormat(locale).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字节单位转换
|
||||||
|
* @param bytes - 字节数
|
||||||
|
* @param decimals - 小数位数,默认为 2
|
||||||
|
* @returns 格式化后的字符串
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驼峰命名转短横线命名
|
||||||
|
* @param str - 驼峰命名字符串
|
||||||
|
* @returns 短横线命名字符串
|
||||||
|
*/
|
||||||
|
export function camelToKebab(str: string): string {
|
||||||
|
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短横线命名转驼峰命名
|
||||||
|
* @param str - 短横线命名字符串
|
||||||
|
* @returns 驼峰命名字符串
|
||||||
|
*/
|
||||||
|
export function kebabToCamel(str: string): string {
|
||||||
|
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首字母大写
|
||||||
|
* @param str - 输入字符串
|
||||||
|
* @returns 首字母大写后的字符串
|
||||||
|
*/
|
||||||
|
export function capitalizeFirst(str: string): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除字符串中的 HTML 标签
|
||||||
|
* @param html - 包含 HTML 标签的字符串
|
||||||
|
* @returns 纯文本字符串
|
||||||
|
*/
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
return html.replace(/<[^>]*>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成页面 SEO 元数据
|
||||||
|
* @param title - 页面标题
|
||||||
|
* @param description - 页面描述
|
||||||
|
* @param keywords - 关键词
|
||||||
|
* @returns 元数据对象数组
|
||||||
|
*/
|
||||||
|
export function generateSeoMeta(
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
keywords: string[] = []
|
||||||
|
): Array<{ title: string; name: string; content: string }> {
|
||||||
|
return [
|
||||||
|
{ title, name: '', content: title },
|
||||||
|
{ title: '', name: 'description', content: description },
|
||||||
|
{ title: '', name: 'keywords', content: keywords.join(', ') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查对象是否为空
|
||||||
|
* @param obj - 要检查的对象
|
||||||
|
* @returns 是否为空
|
||||||
|
*/
|
||||||
|
export function isEmpty(obj: object): boolean {
|
||||||
|
if (obj === null || obj === undefined) return true;
|
||||||
|
if (Array.isArray(obj)) return obj.length === 0;
|
||||||
|
if (typeof obj === 'object') return Object.keys(obj).length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度合并对象
|
||||||
|
* @param target - 目标对象
|
||||||
|
* @param sources - 源对象数组
|
||||||
|
* @returns 合并后的对象
|
||||||
|
*/
|
||||||
|
export function deepMerge<T extends object>(target: T, ...sources: Partial<T>[]): T {
|
||||||
|
if (!sources.length) return target;
|
||||||
|
const source = sources.shift();
|
||||||
|
|
||||||
|
if (source && typeof source === 'object') {
|
||||||
|
for (const key in source) {
|
||||||
|
if (source[key] && typeof source[key] === 'object') {
|
||||||
|
if (!target[key]) Object.assign(target, { [key]: {} });
|
||||||
|
deepMerge(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
Object.assign(target, { [key]: source[key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deepMerge(target, ...sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖函数
|
||||||
|
* @param func - 要防抖的函数
|
||||||
|
* @param wait - 等待时间(毫秒)
|
||||||
|
* @returns 防抖后的函数
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数
|
||||||
|
* @param func - 要节流的函数
|
||||||
|
* @param limit - 时间限制(毫秒)
|
||||||
|
* @returns 节流后的函数
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
limit: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let inThrottle = false;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => (inThrottle = false), limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
446
src/pages/About.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Target,
|
||||||
|
Heart,
|
||||||
|
Award,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
Building2,
|
||||||
|
Globe,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { Footer } from '../components/Footer';
|
||||||
|
import { COMPANY_INFO } from '../lib/constants';
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle';
|
||||||
|
|
||||||
|
// 发展历程数据
|
||||||
|
const milestones = [
|
||||||
|
{
|
||||||
|
year: '2010',
|
||||||
|
title: '公司成立',
|
||||||
|
description: '示例集团在北京成立,开始布局金融服务业务',
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2012',
|
||||||
|
title: '首次战略融资',
|
||||||
|
description: '完成 A 轮融资,获得知名投资机构认可',
|
||||||
|
icon: TrendingUp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2015',
|
||||||
|
title: '业务扩展',
|
||||||
|
description: '成立科技研发子公司,进军科技领域',
|
||||||
|
icon: Globe,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2018',
|
||||||
|
title: '规模扩张',
|
||||||
|
description: '员工规模突破 200 人,服务客户超过 500 家',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2020',
|
||||||
|
title: '产业投资布局',
|
||||||
|
description: '成立产业投资基金,全面进入投资领域',
|
||||||
|
icon: Target,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2023',
|
||||||
|
title: '集团化运营',
|
||||||
|
description: '正式更名为示例集团,形成多元化业务体系',
|
||||||
|
icon: Award,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2025',
|
||||||
|
title: '新里程碑',
|
||||||
|
description: '管理资产突破 500 亿,员工规模超过 500 人',
|
||||||
|
icon: Calendar,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 核心价值观数据
|
||||||
|
const coreValues = [
|
||||||
|
{
|
||||||
|
icon: Heart,
|
||||||
|
title: '诚信为本',
|
||||||
|
description: '诚信是企业发展的基石,我们始终坚守诚信底线,与客户、合作伙伴建立长期信任关系',
|
||||||
|
color: 'from-red-500 to-red-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Target,
|
||||||
|
title: '创新驱动',
|
||||||
|
description: '创新是企业发展的动力,我们持续投入研发,不断推出创新产品和服务',
|
||||||
|
color: 'from-blue-500 to-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Award,
|
||||||
|
title: '卓越品质',
|
||||||
|
description: '品质是企业生存的根本,我们追求卓越,确保每一个项目都达到最高标准',
|
||||||
|
color: 'from-yellow-500 to-yellow-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: '共赢合作',
|
||||||
|
description: '合作是企业成功的关键,我们与客户、员工、合作伙伴实现互利共赢',
|
||||||
|
color: 'from-green-500 to-green-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 团队成员数据
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
name: '张明远',
|
||||||
|
position: '董事长兼 CEO',
|
||||||
|
bio: '毕业于清华大学金融系,拥有 20 年金融行业经验,曾任多家知名金融机构高管。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '李晓峰',
|
||||||
|
position: '首席财务官 CFO',
|
||||||
|
bio: '持有注册会计师资格,曾在四大会计师事务所工作 15 年,专业财务管理和资本运作专家。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '王建华',
|
||||||
|
position: '首席技术官 CTO',
|
||||||
|
bio: '计算机科学博士,曾在国内外知名科技公司担任技术负责人,拥有多项技术专利。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '陈静雅',
|
||||||
|
position: '首席运营官 COO',
|
||||||
|
bio: 'MBA 学位,拥有丰富的企业运营管理经验,擅长战略规划和流程优化。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 荣誉资质数据
|
||||||
|
const honors = [
|
||||||
|
{ name: '国家级高新技术企业认证', year: '2020' },
|
||||||
|
{ name: '北京市优秀企业', year: '2021' },
|
||||||
|
{ name: '中国最佳雇主品牌', year: '2022' },
|
||||||
|
{ name: '金融科技创新奖', year: '2023' },
|
||||||
|
{ name: '年度优秀企业', year: '2024' },
|
||||||
|
{ name: 'ESG 最佳实践奖', year: '2025' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* About 组件 - 关于我们页面
|
||||||
|
*/
|
||||||
|
export const About: React.FC = () => {
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
usePageTitle('关于我们');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="min-h-screen bg-background"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* 主内容 */}
|
||||||
|
<main>
|
||||||
|
{/* 页面标题区域 */}
|
||||||
|
<section className="pt-32 pb-16 bg-gradient-to-br from-primary to-primary-light">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center text-white"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
关于示例集团
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-white/80 max-w-3xl mx-auto">
|
||||||
|
示例集团成立于 2010 年,是一家集科技研发、金融服务、产业投资于一体的综合性企业集团
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 公司简介 */}
|
||||||
|
<section className="py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-6">
|
||||||
|
公司简介
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6 text-gray-600 leading-relaxed">
|
||||||
|
<p>
|
||||||
|
<strong>{COMPANY_INFO.fullName}</strong>
|
||||||
|
成立于 {COMPANY_INFO.established},总部位于 {COMPANY_INFO.headquarters}。
|
||||||
|
经过十余年的稳健发展,示例集团已形成了以金融服务、科技研发、产业投资为核心的多元化业务体系。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
集团秉承"诚信、创新、共赢"的核心价值观,致力于为客户创造最大价值。
|
||||||
|
我们拥有一支经验丰富、专业高效的管理团队,汇聚了金融、科技、投资等领域的优秀人才。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
截至目前,示例集团已成功为超过 1000 家企业客户提供专业服务,
|
||||||
|
管理资产规模突破 500 亿元人民币,业务范围覆盖全国主要城市。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 核心数据 */}
|
||||||
|
<div className="mt-8 grid grid-cols-3 gap-6">
|
||||||
|
<div className="text-center p-4 bg-primary/5 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold text-primary">15+</div>
|
||||||
|
<div className="text-sm text-gray-600">年发展历程</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-accent/10 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold text-accent-dark">500+</div>
|
||||||
|
<div className="text-sm text-gray-600">员工人数</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-green-100 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold text-green-700">1000+</div>
|
||||||
|
<div className="text-sm text-gray-600">服务客户</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="aspect-[4/3] rounded-2xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src="/images/about-office.jpg"
|
||||||
|
alt="示例集团办公环境"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end justify-center p-8">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<div className="text-4xl font-bold mb-2">示例集团</div>
|
||||||
|
<div className="text-xl opacity-90">稳健前行 · 携手共赢</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 装饰元素 */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 w-32 h-32 bg-accent/20 rounded-2xl -z-10" />
|
||||||
|
<div className="absolute -top-6 -left-6 w-24 h-24 bg-primary/10 rounded-2xl -z-10" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 发展历程时间线 */}
|
||||||
|
<section className="py-20 bg-white" ref={timelineRef}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
发展历程
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
十余年稳健发展,见证示例成长
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 时间线 */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* 中轴线 */}
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 w-0.5 h-full bg-gradient-to-b from-primary via-accent to-primary-light hidden md:block" />
|
||||||
|
|
||||||
|
{/* 时间线项目 */}
|
||||||
|
<div className="space-y-12">
|
||||||
|
{milestones.map((milestone, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={milestone.year}
|
||||||
|
className={`flex items-center gap-8 ${
|
||||||
|
index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'
|
||||||
|
}`}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<div
|
||||||
|
className={`p-6 bg-white rounded-2xl shadow-sm border border-gray-100 hover:shadow-md transition-shadow ${
|
||||||
|
index % 2 === 0 ? 'md:pr-12' : 'md:pl-12'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-block px-3 py-1 bg-accent/20 text-accent-dark text-sm font-semibold rounded-full mb-3">
|
||||||
|
{milestone.year}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-semibold text-primary-dark mb-2">
|
||||||
|
{milestone.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">{milestone.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间图标 */}
|
||||||
|
<div className="hidden md:flex items-center justify-center w-12 h-12 rounded-full bg-primary text-white shadow-lg z-10">
|
||||||
|
<milestone.icon size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 空白区域 */}
|
||||||
|
<div className="flex-1 hidden md:block" />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 企业文化 */}
|
||||||
|
<section className="py-20 bg-primary-dark text-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold mb-4">企业文化</h2>
|
||||||
|
<p className="text-lg text-gray-300">
|
||||||
|
核心价值观驱动企业发展
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{coreValues.map((value, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={value.title}
|
||||||
|
className="text-center p-6 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br ${value.color} text-white mb-5`}
|
||||||
|
>
|
||||||
|
<value.icon size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-3">{value.title}</h3>
|
||||||
|
<p className="text-gray-300 text-sm leading-relaxed">
|
||||||
|
{value.description}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 团队介绍 */}
|
||||||
|
<section className="py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
管理团队
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
汇聚行业精英,共创企业未来
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{teamMembers.map((member, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={member.name}
|
||||||
|
className="text-center p-6 bg-white rounded-2xl shadow-sm border border-gray-100 hover:shadow-lg transition-shadow"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
{/* 团队成员头像 */}
|
||||||
|
<div className="w-24 h-24 mx-auto mb-4 rounded-full overflow-hidden ring-4 ring-primary/10">
|
||||||
|
<div className="w-full h-full rounded-full bg-gradient-to-br from-primary to-primary-light flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||||
|
{member.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-primary-dark">
|
||||||
|
{member.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-accent font-medium mb-3">{member.position}</p>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed">
|
||||||
|
{member.bio}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 资质荣誉 */}
|
||||||
|
<section className="py-20 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
资质荣誉
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
行业认可,品质保证
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{honors.map((honor, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={honor.name}
|
||||||
|
className="flex items-center gap-4 p-5 bg-background rounded-xl hover:shadow-md transition-shadow"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-16 h-16 rounded-lg bg-gradient-to-br from-accent/20 to-accent/10 flex items-center justify-center">
|
||||||
|
<Award size={28} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-primary-dark">
|
||||||
|
{honor.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500">获得年份:{honor.year}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Footer />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default About;
|
||||||
119
src/pages/Categories.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { Footer } from '../components/Footer'
|
||||||
|
import { Categories } from '../clientsdk/sdk.gen'
|
||||||
|
import { createClient } from '../clientsdk/client'
|
||||||
|
import { customQuerySerializer } from '../clientsdk/querySerializer'
|
||||||
|
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
querySerializer: customQuerySerializer,
|
||||||
|
headers: {
|
||||||
|
'X-Tenant-Slug': TENANT_SLUG,
|
||||||
|
'X-API-Key': TENANT_API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CategoriesPage: React.FC = () => {
|
||||||
|
usePageTitle('文章分类')
|
||||||
|
const [categories, setCategories] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const response = await Categories.listCategories({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setCategories((response as any)?.data?.docs || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
console.error('获取分类失败:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCategories()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">📂 文章分类</h2>
|
||||||
|
<p className="text-gray-600">浏览所有分类</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<strong>错误:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-lg shadow-sm overflow-hidden animate-pulse">
|
||||||
|
<div className="aspect-[16/9] bg-gray-200"></div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: categories.map((category) => (
|
||||||
|
<a
|
||||||
|
key={category.id}
|
||||||
|
href={`/categories/${category.slug}`}
|
||||||
|
className="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow group"
|
||||||
|
>
|
||||||
|
<div className="aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/10 to-primary-light/10">
|
||||||
|
<img
|
||||||
|
src={`/images/category-${category.slug}.jpg`}
|
||||||
|
alt={category.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
target.nextElementSibling?.classList.remove('hidden');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full flex items-center justify-center hidden">
|
||||||
|
<div className="text-center text-primary">
|
||||||
|
<div className="text-4xl mb-2">📁</div>
|
||||||
|
<p className="text-sm font-medium">{category.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">{category.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600">查看该分类下的所有文章</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && categories.length === 0 && !error && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 text-lg">暂无分类</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
src/pages/CategoryDetail.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { Footer } from '../components/Footer'
|
||||||
|
import { PostCard } from '../components/PostCard'
|
||||||
|
import { PostCardSkeleton } from '../components/PostCardSkeleton'
|
||||||
|
import { Posts, Categories } from '../clientsdk/sdk.gen'
|
||||||
|
import { createClient } from '../clientsdk/client'
|
||||||
|
import { customQuerySerializer } from '../clientsdk/querySerializer'
|
||||||
|
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
querySerializer: customQuerySerializer,
|
||||||
|
headers: {
|
||||||
|
'X-Tenant-Slug': TENANT_SLUG,
|
||||||
|
'X-API-Key': TENANT_API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CategoryDetail: React.FC = () => {
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
usePageTitle('分类详情')
|
||||||
|
const [posts, setPosts] = useState<any[]>([])
|
||||||
|
const [category, setCategory] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const [categoriesRes, postsRes] = await Promise.all([
|
||||||
|
// Use listCategories with where filter since findCategoryById doesn't support slug lookup
|
||||||
|
Categories.listCategories({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
equals: slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Posts.listPosts({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
limit: 100,
|
||||||
|
sort: '-createdAt',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const categoryDocs = (categoriesRes as any)?.data?.docs || []
|
||||||
|
if (categoryDocs[0]) {
|
||||||
|
setCategory(categoryDocs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDocs = (postsRes as any)?.data?.docs || []
|
||||||
|
// categories is an array, check if any category in the array matches the slug
|
||||||
|
const categoryPosts = allDocs.filter((post: any) =>
|
||||||
|
post.categories?.some((cat: any) => cat.slug === slug)
|
||||||
|
)
|
||||||
|
|
||||||
|
setPosts(categoryPosts)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
console.error('获取数据失败:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const stripHtml = (html: string): string => {
|
||||||
|
const tmp = document.createElement('div')
|
||||||
|
tmp.innerHTML = html
|
||||||
|
return tmp.textContent || tmp.innerText || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryTitle = (post: any): string | undefined => {
|
||||||
|
// categories is an array, get the first one
|
||||||
|
return post.categories?.[0]?.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostClick = (postSlug: string) => {
|
||||||
|
window.location.href = `/posts/${postSlug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<section className="mb-12">
|
||||||
|
{category && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="aspect-[16/8] rounded-2xl overflow-hidden mb-6">
|
||||||
|
<img
|
||||||
|
src={`/images/category-${category.slug}-header.jpg`}
|
||||||
|
alt={category.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
target.nextElementSibling?.classList.remove('hidden');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-primary/10 to-primary-light/10 flex items-center justify-center hidden">
|
||||||
|
<div className="text-center text-primary">
|
||||||
|
<div className="text-6xl mb-4">📂</div>
|
||||||
|
<p className="text-2xl font-medium">{category.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
📂 {category?.title || '分类'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">探索该分类下的所有内容</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<strong>错误:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
|
||||||
|
: posts.map((post) => (
|
||||||
|
<PostCard
|
||||||
|
key={post.id}
|
||||||
|
title={post.title}
|
||||||
|
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)}
|
||||||
|
category={getCategoryTitle(post)}
|
||||||
|
date={formatDate(post.createdAt)}
|
||||||
|
onClick={() => handlePostClick(post.slug)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && posts.length === 0 && !error && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 text-lg">该分类下暂无文章</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
576
src/pages/Contact.tsx
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Clock,
|
||||||
|
Send,
|
||||||
|
User,
|
||||||
|
MessageSquare,
|
||||||
|
Building2,
|
||||||
|
Briefcase,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { Footer } from '../components/Footer';
|
||||||
|
import { COMPANY_INFO, CONTACT_INFO } from '../lib/constants';
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact 组件 - 联系我们页面
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 招聘信息数据
|
||||||
|
const jobPositions = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '高级投资经理',
|
||||||
|
department: '投资部',
|
||||||
|
location: '北京',
|
||||||
|
type: '全职',
|
||||||
|
salary: '30K-50K/月',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Java 开发工程师',
|
||||||
|
department: '技术部',
|
||||||
|
location: '北京',
|
||||||
|
type: '全职',
|
||||||
|
salary: '25K-40K/月',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '财务顾问',
|
||||||
|
department: '金融部',
|
||||||
|
location: '上海',
|
||||||
|
type: '全职',
|
||||||
|
salary: '20K-35K/月',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '产品经理',
|
||||||
|
department: '产品部',
|
||||||
|
location: '北京',
|
||||||
|
type: '全职',
|
||||||
|
salary: '28K-45K/月',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 表单状态类型
|
||||||
|
interface ContactFormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
subject?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact 组件
|
||||||
|
*/
|
||||||
|
export const Contact: React.FC = () => {
|
||||||
|
usePageTitle('联系我们');
|
||||||
|
const [formData, setFormData] = useState<ContactFormData>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
subject: '',
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
newErrors.name = '请输入您的姓名';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
newErrors.email = '请输入您的邮箱';
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
newErrors.email = '请输入有效的邮箱地址';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.subject.trim()) {
|
||||||
|
newErrors.subject = '请输入邮件主题';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.message.trim()) {
|
||||||
|
newErrors.message = '请输入留言内容';
|
||||||
|
} else if (formData.message.trim().length < 10) {
|
||||||
|
newErrors.message = '留言内容至少需要 10 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理输入变化
|
||||||
|
const handleInputChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
// 清除对应字段的错误
|
||||||
|
if (errors[name as keyof FormErrors]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理表单提交
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// 模拟表单提交
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setSubmitSuccess(true);
|
||||||
|
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||||
|
|
||||||
|
// 3秒后重置成功状态
|
||||||
|
setTimeout(() => setSubmitSuccess(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="min-h-screen bg-background"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* 主内容 */}
|
||||||
|
<main>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<section className="pt-32 pb-16 bg-gradient-to-br from-primary to-primary-light">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center text-white"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
联系我们
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-white/80 max-w-3xl mx-auto">
|
||||||
|
诚挚期待与您合作,欢迎随时与我们联系
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 联系信息 */}
|
||||||
|
<section className="py-16 -mt-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{CONTACT_INFO.map((info, index) => {
|
||||||
|
const Icon = info.icon;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={info.id}
|
||||||
|
className="bg-white rounded-xl shadow-lg p-6 text-center hover:shadow-xl transition-shadow"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-accent/20 mb-4">
|
||||||
|
<Icon size={28} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm text-gray-500 mb-2">{info.title}</h3>
|
||||||
|
<p className="font-medium text-primary-dark">{info.content}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 联系表单和地图 */}
|
||||||
|
<section className="py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12">
|
||||||
|
{/* 联系表单 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||||
|
<h2 className="text-2xl font-bold text-primary-dark mb-2">
|
||||||
|
发送留言
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
请填写以下表单,我们将尽快与您联系
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 成功提示 */}
|
||||||
|
{submitSuccess && (
|
||||||
|
<motion.div
|
||||||
|
className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl flex items-center gap-3"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
>
|
||||||
|
<CheckCircle size={24} className="text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-green-800">提交成功!</p>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
我们已收到您的留言,会尽快回复您。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* 姓名 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
姓名 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User
|
||||||
|
size={20}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="请输入您的姓名"
|
||||||
|
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 outline-none transition-all ${
|
||||||
|
errors.name
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-gray-200 focus:border-primary'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邮箱 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
邮箱 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail
|
||||||
|
size={20}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="请输入您的邮箱"
|
||||||
|
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 outline-none transition-all ${
|
||||||
|
errors.email
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-gray-200 focus:border-primary'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主题 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="subject"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
主题 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare
|
||||||
|
size={20}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 outline-none transition-all appearance-none bg-white ${
|
||||||
|
errors.subject
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-gray-200 focus:border-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="">请选择主题</option>
|
||||||
|
<option value="业务咨询">业务咨询</option>
|
||||||
|
<option value="投资合作">投资合作</option>
|
||||||
|
<option value="技术支持">技术支持</option>
|
||||||
|
<option value="媒体合作">媒体合作</option>
|
||||||
|
<option value="其他">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{errors.subject && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.subject}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 留言内容 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="message"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
留言内容 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
rows={5}
|
||||||
|
placeholder="请详细描述您的需求..."
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 outline-none transition-all resize-none ${
|
||||||
|
errors.message
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-gray-200 focus:border-primary'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.message && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提交按钮 */}
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`w-full py-4 rounded-lg font-medium text-white transition-all flex items-center justify-center gap-2 ${
|
||||||
|
isSubmitting
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-primary hover:bg-primary-light'
|
||||||
|
}`}
|
||||||
|
whileHover={!isSubmitting ? { scale: 1.02 } : {}}
|
||||||
|
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
提交中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={20} />
|
||||||
|
提交留言
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 地图和地址信息 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{/* 地图和办公环境 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-2 mb-6">
|
||||||
|
<div className="aspect-[4/3] rounded-xl overflow-hidden relative">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=1200&h=900&fit=crop"
|
||||||
|
alt="示例集团总部大楼 - 现代化办公大厦"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 bg-white/95 backdrop-blur-sm rounded-lg p-3 shadow-lg">
|
||||||
|
<p className="text-primary-dark font-medium text-sm mb-1">
|
||||||
|
📍 示例集团总部
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-xs">
|
||||||
|
北京市朝阳区建国路88号示例大厦
|
||||||
|
</p>
|
||||||
|
<motion.button
|
||||||
|
className="mt-2 px-4 py-1 bg-primary text-white rounded text-xs hover:bg-primary-light transition-colors"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
在地图中查看
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 办公环境展示 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
||||||
|
<div className="aspect-[16/9] overflow-hidden">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&h=675&fit=crop"
|
||||||
|
alt="示例集团办公环境 - 现代化工作空间"
|
||||||
|
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 公司详细信息 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-primary-dark mb-4">
|
||||||
|
公司信息
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MapPin size={20} className="text-accent mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">总部地址</p>
|
||||||
|
<p className="text-primary-dark">
|
||||||
|
{COMPANY_INFO.headquarters}示例大厦
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Phone size={20} className="text-accent mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">服务热线</p>
|
||||||
|
<p className="text-primary-dark">{COMPANY_INFO.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Mail size={20} className="text-accent mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">商务邮箱</p>
|
||||||
|
<p className="text-primary-dark">{COMPANY_INFO.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock size={20} className="text-accent mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">工作时间</p>
|
||||||
|
<p className="text-primary-dark">{COMPANY_INFO.workingHours}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 招聘信息 */}
|
||||||
|
<section className="py-20 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-12"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
加入示例
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
我们正在寻找优秀的人才,与我们共创未来
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{jobPositions.map((job, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={job.id}
|
||||||
|
className="p-6 bg-background rounded-xl border border-gray-100 hover:shadow-md transition-shadow"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-primary-dark mb-1">
|
||||||
|
{job.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-4">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Building2 size={14} />
|
||||||
|
{job.department}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin size={14} />
|
||||||
|
{job.location}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-3 py-1 bg-accent/20 text-accent-dark text-sm font-medium rounded-full">
|
||||||
|
{job.salary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
|
<span className="text-sm text-gray-500">{job.type}</span>
|
||||||
|
<motion.button
|
||||||
|
className="flex items-center gap-1 text-primary font-medium text-sm hover:text-primary-light"
|
||||||
|
whileHover={{ x: 3 }}
|
||||||
|
>
|
||||||
|
了解详情
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="text-center mt-8"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<motion.button
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-3 border-2 border-primary text-primary font-medium rounded-lg hover:bg-primary hover:text-white transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Briefcase size={20} />
|
||||||
|
查看全部职位
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Footer />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Contact;
|
||||||
48
src/pages/Home.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { Footer } from '../components/Footer';
|
||||||
|
import { Hero } from '../components/Hero';
|
||||||
|
import { AboutSection } from '../components/Home/AboutSection';
|
||||||
|
import { ServicesSection } from '../components/Home/ServicesSection';
|
||||||
|
import { NewsSection } from '../components/Home/NewsSection';
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home 组件 - 企业官网首页
|
||||||
|
*/
|
||||||
|
export const Home: React.FC = () => {
|
||||||
|
usePageTitle('首页');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="min-h-screen bg-background"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{/* 顶部导航栏 */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<main>
|
||||||
|
{/* Hero 区域 - 首页大图 */}
|
||||||
|
<Hero />
|
||||||
|
|
||||||
|
{/* 关于我们区域 */}
|
||||||
|
<AboutSection />
|
||||||
|
|
||||||
|
{/* 核心业务区域 */}
|
||||||
|
<ServicesSection />
|
||||||
|
|
||||||
|
{/* 新闻资讯区域 */}
|
||||||
|
<NewsSection />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Footer />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
669
src/pages/News.tsx
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
Tag,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { Footer } from '../components/Footer';
|
||||||
|
import { formatDate } from '../lib/utils';
|
||||||
|
import { NEWS_CATEGORIES } from '../lib/constants';
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle';
|
||||||
|
|
||||||
|
// 模拟新闻数据
|
||||||
|
const allNews = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '示例集团荣获"2025年度优秀企业"称号',
|
||||||
|
excerpt: '在近日举办的年度企业评选活动中,示例集团凭借其卓越的经营业绩和社会责任表现,荣获"2025年度优秀企业"称号。这一荣誉是对示例集团多年来坚持创新发展的肯定。',
|
||||||
|
category: 'company',
|
||||||
|
date: '2025-12-20',
|
||||||
|
readCount: 1256,
|
||||||
|
image: null,
|
||||||
|
isHot: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '金融科技创新论坛圆满落幕,示例集团分享行业洞察',
|
||||||
|
excerpt: '示例集团受邀参加金融科技创新论坛,与行业专家共同探讨金融科技发展趋势,分享公司在数字化转型方面的实践经验。与会嘉宾对示例集团的创新成果给予了高度评价。',
|
||||||
|
category: 'industry',
|
||||||
|
date: '2025-12-15',
|
||||||
|
readCount: 892,
|
||||||
|
image: null,
|
||||||
|
isHot: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '示例集团完成新一轮战略融资,估值突破百亿',
|
||||||
|
excerpt: '示例集团宣布完成新一轮战略融资,本轮融资由知名投资机构领投,估值突破百亿元人民币,标志着公司发展进入新阶段。此轮融资将用于加大技术研发和市场拓展力度。',
|
||||||
|
category: 'company',
|
||||||
|
date: '2025-12-10',
|
||||||
|
readCount: 2341,
|
||||||
|
image: null,
|
||||||
|
isHot: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '示例集团发布2025年度社会责任报告',
|
||||||
|
excerpt: '示例集团正式发布《2025年度社会责任报告》,全面展示了公司在环境保护、社会公益、公司治理等方面的实践成果。报告显示,示例集团在ESG领域取得了显著进步。',
|
||||||
|
category: 'company',
|
||||||
|
date: '2025-12-05',
|
||||||
|
readCount: 567,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '人工智能赋能金融服务行业论坛成功举办',
|
||||||
|
excerpt: '由示例集团主办的人工智能赋能金融服务行业论坛在北京成功举办。来自金融机构、科技公司、学术机构的专家学者共同探讨AI技术在金融服务领域的应用前景。',
|
||||||
|
category: 'industry',
|
||||||
|
date: '2025-11-28',
|
||||||
|
readCount: 743,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: '示例集团获得国家级高新技术企业认证',
|
||||||
|
excerpt: '示例集团正式获得国家级高新技术企业认证,这标志着集团在技术创新和研发投入方面获得了国家层面的认可。示例集团将继续加大研发投入,提升自主创新能力。',
|
||||||
|
category: 'achievement',
|
||||||
|
date: '2025-11-20',
|
||||||
|
readCount: 1089,
|
||||||
|
image: null,
|
||||||
|
isHot: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: '示例集团与清华大学签署战略合作协议',
|
||||||
|
excerpt: '示例集团与清华大学正式签署战略合作协议,双方将在人才培养、技术研发、成果转化等方面开展深度合作。这一合作将为示例集团的创新发展提供强大的智力支持。',
|
||||||
|
category: 'company',
|
||||||
|
date: '2025-11-15',
|
||||||
|
readCount: 621,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: '数字化转型趋势报告发布,示例集团引领行业发展',
|
||||||
|
excerpt: '示例集团研究院发布《2025企业数字化转型趋势报告》,深入分析了当前数字化转型的发展态势和未来趋势。报告指出,数字化转型已成为企业提升竞争力的关键路径。',
|
||||||
|
category: 'industry',
|
||||||
|
date: '2025-11-08',
|
||||||
|
readCount: 456,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: '示例集团获评"最佳雇主品牌"荣誉称号',
|
||||||
|
excerpt: '在2025年度人力资源管理峰会上,示例集团凭借其在人才培养、员工发展、企业文化等方面的卓越表现,荣获"最佳雇主品牌"荣誉称号。这一荣誉体现了员工对示例集团的高度认可。',
|
||||||
|
category: 'achievement',
|
||||||
|
date: '2025-11-01',
|
||||||
|
readCount: 389,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: '示例集团启动"绿色金融"专项计划',
|
||||||
|
excerpt: '示例集团正式宣布启动"绿色金融"专项计划,计划在未来三年内投入100亿元支持绿色产业发展。这一计划的推出,体现了示例集团积极践行可持续发展理念的决心。',
|
||||||
|
category: 'company',
|
||||||
|
date: '2025-10-25',
|
||||||
|
readCount: 723,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
title: '产业投资基金发展趋势研讨会在京举行',
|
||||||
|
excerpt: '由示例集团主办的产业投资基金发展趋势研讨会在北京举行。来自监管部门、投资机构、产业龙头企业的代表就产业投资基金的发展方向和投资策略进行了深入探讨。',
|
||||||
|
category: 'industry',
|
||||||
|
date: '2025-10-18',
|
||||||
|
readCount: 312,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
title: '示例集团入选"北京民营企业百强"榜单',
|
||||||
|
excerpt: '北京市工商联发布2025年度北京民营企业百强榜单,示例集团凭借优异的经营业绩和创新能力,成功入选该榜单。这一荣誉是对示例集团综合实力的又一次肯定。',
|
||||||
|
category: 'achievement',
|
||||||
|
date: '2025-10-10',
|
||||||
|
readCount: 567,
|
||||||
|
image: null,
|
||||||
|
isHot: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 新闻分类映射
|
||||||
|
const categoryMap: Record<string, { label: string; color: string }> = {
|
||||||
|
all: { label: '全部', color: 'bg-primary text-white' },
|
||||||
|
company: { label: '公司动态', color: 'bg-primary/10 text-primary' },
|
||||||
|
industry: { label: '行业资讯', color: 'bg-accent/20 text-accent-dark' },
|
||||||
|
achievement: { label: '荣誉资质', color: 'bg-green-100 text-green-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每页新闻数量
|
||||||
|
const ITEMS_PER_PAGE = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻卡片组件
|
||||||
|
*/
|
||||||
|
const NewsCard: React.FC<{ news: typeof allNews[0]; index: number }> = ({
|
||||||
|
news,
|
||||||
|
index,
|
||||||
|
}) => {
|
||||||
|
const category = categoryMap[news.category] || categoryMap.company;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-lg transition-all duration-300 group"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ delay: index * 0.05, duration: 0.4 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
{/* 图片区域 */}
|
||||||
|
<Link to={`/news/${news.id}`} className="block aspect-[16/9] relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={`https://images.unsplash.com/photo-${
|
||||||
|
news.category === 'company' ? '1560179707-f14e90ef3623' :
|
||||||
|
news.category === 'industry' ? '1504868584819-f8e8b4b6d7e3' :
|
||||||
|
'1552664730-d307ca884978'
|
||||||
|
}?w=800&h=450&fit=crop&auto=format&q=80`}
|
||||||
|
alt={news.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||||
|
{/* 分类标签 */}
|
||||||
|
<span className={`absolute top-4 left-4 px-3 py-1 text-xs font-medium rounded-full backdrop-blur-sm ${category.color}`}>
|
||||||
|
{category.label}
|
||||||
|
</span>
|
||||||
|
{/* 热门标签 */}
|
||||||
|
{news.isHot && (
|
||||||
|
<span className="absolute top-4 right-4 px-3 py-1 text-xs font-medium bg-red-500 text-white rounded-full flex items-center gap-1 backdrop-blur-sm">
|
||||||
|
<TrendingUp size={12} />
|
||||||
|
热门
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="p-6">
|
||||||
|
{/* 元信息 */}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<time dateTime={news.date}>{formatDate(news.date, 'YYYY年MM月DD日')}</time>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye size={14} />
|
||||||
|
<span>{news.readCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<Link to={`/news/${news.id}`}>
|
||||||
|
<h3 className="text-lg font-semibold text-primary-dark mb-3 line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{news.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 摘要 */}
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2 mb-4">
|
||||||
|
{news.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 阅读更多 */}
|
||||||
|
<Link
|
||||||
|
to={`/news/${news.id}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-primary group-hover:text-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
阅读全文
|
||||||
|
<motion.span
|
||||||
|
initial={{ x: 0 }}
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</motion.span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧边栏热门新闻组件
|
||||||
|
*/
|
||||||
|
const HotNewsWidget: React.FC<{ news: typeof allNews[0]; rank: number }> = ({
|
||||||
|
news,
|
||||||
|
rank,
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
to={`/news/${news.id}`}
|
||||||
|
className="flex items-start gap-4 p-4 bg-background rounded-xl hover:bg-white hover:shadow-sm transition-all group"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary font-bold text-sm">
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-primary-dark mb-1 line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{news.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span>{formatDate(news.date, 'YYYY-MM-DD')}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{news.readCount} 阅读</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页组件
|
||||||
|
*/
|
||||||
|
const Pagination: React.FC<{
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}> = ({ currentPage, totalPages, onPageChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-12">
|
||||||
|
{/* 上一页按钮 */}
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* 页码 */}
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
|
<motion.button
|
||||||
|
key={page}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
currentPage === page
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-gray-600 bg-white border border-gray-200 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 下一页按钮 */}
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数字人视频播放器组件
|
||||||
|
*/
|
||||||
|
const DigitalHumanVideo: React.FC = () => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(true);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-12"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="grid lg:grid-cols-5 gap-6">
|
||||||
|
{/* 视频播放器 */}
|
||||||
|
<div className="lg:col-span-3 relative bg-gradient-to-br from-primary/5 to-primary-light/10">
|
||||||
|
<div className="aspect-video relative">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
muted={isMuted}
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
poster="https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=1200&h=675&fit=crop"
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{/* 播放控制按钮 */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors group">
|
||||||
|
<motion.button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="w-16 h-16 rounded-full bg-white/90 backdrop-blur-sm flex items-center justify-center text-primary shadow-lg group-hover:scale-110 transition-transform"
|
||||||
|
whileHover={{ scale: 1.15 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={28} /> : <Play size={28} className="ml-1" />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音量控制 */}
|
||||||
|
<div className="absolute bottom-4 right-4">
|
||||||
|
<motion.button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="w-10 h-10 rounded-full bg-white/90 backdrop-blur-sm flex items-center justify-center text-primary shadow-lg"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 直播标签 */}
|
||||||
|
<div className="absolute top-4 left-4">
|
||||||
|
<span className="px-3 py-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||||
|
数字人播报
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频信息 */}
|
||||||
|
<div className="lg:col-span-2 p-6 flex flex-col justify-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-3">
|
||||||
|
AI 数字人
|
||||||
|
</span>
|
||||||
|
<h3 className="text-2xl font-bold text-primary-dark mb-3">
|
||||||
|
示例集团新闻播报
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 leading-relaxed mb-4">
|
||||||
|
欢迎收看示例集团最新资讯播报。我们使用先进的AI数字人技术,为您带来最新的企业动态、行业资讯和重要公告。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
|
<span>24小时不间断播报</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
|
<span>实时更新企业资讯</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
|
<span>多语言智能播报</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
className="mt-6 px-6 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-light transition-colors flex items-center justify-center gap-2"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Play size={18} />
|
||||||
|
了解更多
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* News 组件 - 新闻资讯页面
|
||||||
|
*/
|
||||||
|
export const News: React.FC = () => {
|
||||||
|
usePageTitle('新闻资讯');
|
||||||
|
const [activeCategory, setActiveCategory] = useState('all');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 根据分类筛选新闻
|
||||||
|
const filteredNews =
|
||||||
|
activeCategory === 'all'
|
||||||
|
? allNews
|
||||||
|
: allNews.filter((news) => news.category === activeCategory);
|
||||||
|
|
||||||
|
// 计算分页
|
||||||
|
const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const currentNews = filteredNews.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
// 获取热门新闻 TOP 5
|
||||||
|
const hotNews = allNews
|
||||||
|
.filter((news) => news.isHot)
|
||||||
|
.sort((a, b) => b.readCount - a.readCount)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
// 切换分类时重置分页
|
||||||
|
const handleCategoryChange = (category: string) => {
|
||||||
|
setActiveCategory(category);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="min-h-screen bg-background"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* 主内容 */}
|
||||||
|
<main>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<section className="pt-32 pb-16 bg-gradient-to-br from-primary to-primary-light">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center text-white"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
新闻资讯
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-white/80 max-w-3xl mx-auto">
|
||||||
|
了解示例集团最新动态和行业资讯
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 新闻内容区域 */}
|
||||||
|
<section className="py-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* 数字人视频播放器 */}
|
||||||
|
<DigitalHumanVideo />
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
|
{/* 左侧新闻列表 */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{/* 分类筛选 */}
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-wrap gap-2 mb-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{NEWS_CATEGORIES.map((category) => (
|
||||||
|
<motion.button
|
||||||
|
key={category.id}
|
||||||
|
onClick={() => handleCategoryChange(category.id)}
|
||||||
|
className={`px-5 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||||
|
activeCategory === category.id
|
||||||
|
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-200'
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 新闻数量提示 */}
|
||||||
|
<motion.p
|
||||||
|
className="text-sm text-gray-500 mb-6"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
共 <span className="font-medium text-primary">{filteredNews.length}</span> 条新闻
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* 新闻列表 */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${activeCategory}-${currentPage}`}
|
||||||
|
className="grid sm:grid-cols-2 gap-6"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{currentNews.map((news, index) => (
|
||||||
|
<NewsCard key={news.id} news={news} index={index} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{currentNews.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
className="text-center py-16"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 mb-4">
|
||||||
|
<Clock size={32} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-600 mb-2">
|
||||||
|
暂无新闻
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
该分类下暂无新闻,请选择其他分类
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧侧边栏 */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="sticky top-24 space-y-8">
|
||||||
|
{/* 热门新闻 */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-2 bg-red-100 rounded-lg">
|
||||||
|
<TrendingUp size={20} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-primary-dark">
|
||||||
|
热门新闻
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{hotNews.map((news, index) => (
|
||||||
|
<HotNewsWidget key={news.id} news={news} rank={index + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 关注我们 */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-gradient-to-br from-primary to-primary-light rounded-2xl p-6 text-white"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">关注我们</h3>
|
||||||
|
<p className="text-white/80 text-sm mb-4">
|
||||||
|
第一时间获取最新企业动态和行业资讯
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<motion.button
|
||||||
|
className="flex-1 py-2 bg-white/20 rounded-lg text-sm font-medium hover:bg-white/30 transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
微信公众号
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
className="flex-1 py-2 bg-white/20 rounded-lg text-sm font-medium hover:bg-white/30 transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
官方微博
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Footer />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default News;
|
||||||
160
src/pages/PostDetail.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { Footer } from '../components/Footer'
|
||||||
|
import { Posts } from '../clientsdk/sdk.gen'
|
||||||
|
import { createClient } from '../clientsdk/client'
|
||||||
|
import { customQuerySerializer } from '../clientsdk/querySerializer'
|
||||||
|
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
querySerializer: customQuerySerializer,
|
||||||
|
headers: {
|
||||||
|
'X-Tenant-Slug': TENANT_SLUG,
|
||||||
|
'X-API-Key': TENANT_API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PostDetail: React.FC = () => {
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
usePageTitle('文章详情')
|
||||||
|
const [post, setPost] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
|
||||||
|
const fetchPost = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Use listPosts with where filter since findPostById doesn't support slug lookup
|
||||||
|
const response = await Posts.listPosts({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
equals: slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const docs = (response as any)?.data?.docs || []
|
||||||
|
setPost(docs[0] || null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
console.error('获取文章失败:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPost()
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const getCategoryTitle = (p: any): string | undefined => {
|
||||||
|
// categories is an array, get the first one
|
||||||
|
return p?.categories?.[0]?.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/4 mb-6"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 bg-gray-200 rounded"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !post) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600 text-lg">{error || '文章不存在'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<article>
|
||||||
|
<header className="mb-8">
|
||||||
|
{getCategoryTitle(post) && (
|
||||||
|
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm mb-4">
|
||||||
|
{getCategoryTitle(post)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
|
||||||
|
<div className="flex items-center text-gray-600 text-sm">
|
||||||
|
<span>{formatDate(post.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{post.heroImage && (
|
||||||
|
<img
|
||||||
|
src={post.heroImage.url}
|
||||||
|
alt={post.heroImage.alt || post.title}
|
||||||
|
className="w-full h-auto rounded-lg mb-8"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="prose prose-lg max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content_html || '' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
558
src/pages/Services.tsx
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
Cpu,
|
||||||
|
Building2,
|
||||||
|
Briefcase,
|
||||||
|
CheckCircle,
|
||||||
|
Target,
|
||||||
|
BarChart3,
|
||||||
|
Shield,
|
||||||
|
Lightbulb,
|
||||||
|
Rocket,
|
||||||
|
Handshake,
|
||||||
|
PieChart,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Header } from '../components/Header';
|
||||||
|
import { Footer } from '../components/Footer';
|
||||||
|
import { SERVICES } from '../lib/constants';
|
||||||
|
import { usePageTitle } from '../hooks/usePageTitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services 组件 - 产品服务页面
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 详细服务数据
|
||||||
|
const serviceDetails = {
|
||||||
|
finance: {
|
||||||
|
description: '我们的金融服务涵盖资产配置、投资顾问、风险管理等全方位金融解决方案,帮助客户实现财富稳健增长。',
|
||||||
|
features: [
|
||||||
|
{ icon: PieChart, title: '资产配置', desc: '根据客户风险偏好和投资目标,提供个性化的资产配置方案' },
|
||||||
|
{ icon: TrendingUp, title: '投资顾问', desc: '专业投资顾问团队,提供一对一的投资咨询和指导服务' },
|
||||||
|
{ icon: Shield, title: '风险管理', desc: '完善的风险控制体系,确保投资安全可控' },
|
||||||
|
{ icon: BarChart3, title: '业绩报告', desc: '定期提供详细的业绩报告和分析,让客户清晰了解投资状况' },
|
||||||
|
],
|
||||||
|
process: [
|
||||||
|
{ step: '01', title: '需求沟通', desc: '了解客户投资目标和风险偏好' },
|
||||||
|
{ step: '02', title: '方案设计', desc: '制定个性化资产配置方案' },
|
||||||
|
{ step: '03', title: '投资执行', desc: '按照方案进行投资操作' },
|
||||||
|
{ step: '04', title: '持续跟踪', desc: '定期调整和优化投资组合' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tech: {
|
||||||
|
description: '我们聚焦人工智能、大数据、云计算等前沿技术,为企业提供全方位的数字化转型解决方案。',
|
||||||
|
features: [
|
||||||
|
{ icon: Cpu, title: 'AI 解决方案', desc: '利用人工智能技术,帮助企业实现智能化升级' },
|
||||||
|
{ icon: Lightbulb, title: '大数据分析', desc: '深度挖掘数据价值,提供决策支持' },
|
||||||
|
{ icon: Zap, title: '云服务', desc: '为企业提供稳定、高效的云计算服务' },
|
||||||
|
{ icon: Rocket, title: '产品研发', desc: '定制化软件产品开发和创新解决方案' },
|
||||||
|
],
|
||||||
|
process: [
|
||||||
|
{ step: '01', title: '需求分析', desc: '深入了解企业数字化需求' },
|
||||||
|
{ step: '02', title: '方案规划', desc: '制定数字化转型路线图' },
|
||||||
|
{ step: '03', title: '开发实施', desc: '按照方案进行系统开发和部署' },
|
||||||
|
{ step: '04', title: '运维支持', desc: '提供持续的技术支持和维护服务' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
investment: {
|
||||||
|
description: '我们专注于新兴产业投资,通过战略投资推动产业升级,为投资者创造丰厚回报。',
|
||||||
|
features: [
|
||||||
|
{ icon: Target, title: '股权投资', desc: '聚焦高成长性企业,把握产业投资机会' },
|
||||||
|
{ icon: Building2, title: '产业基金', desc: '设立专项产业基金,支持重点产业发展' },
|
||||||
|
{ icon: Handshake, title: '并购重组', desc: '协助企业进行并购整合,实现规模扩张' },
|
||||||
|
{ icon: Globe, title: '跨境投资', desc: '布局全球市场,寻求国际投资机会' },
|
||||||
|
],
|
||||||
|
process: [
|
||||||
|
{ step: '01', title: '项目筛选', desc: '严格筛选优质投资项目' },
|
||||||
|
{ step: '02', title: '尽职调查', desc: '深入调研项目基本面' },
|
||||||
|
{ step: '03', title: '投资决策', desc: '专业评审委员会决策' },
|
||||||
|
{ step: '04', title: '投后管理', desc: '提供增值服务,助力企业成长' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
consulting: {
|
||||||
|
description: '我们为企业提供战略规划、运营优化、风险管理等专业咨询服务,助力企业持续发展。',
|
||||||
|
features: [
|
||||||
|
{ icon: Target, title: '战略规划', desc: '帮助企业制定长期发展战略' },
|
||||||
|
{ icon: Users, title: '管理咨询', desc: '优化组织架构和业务流程' },
|
||||||
|
{ icon: BarChart3, title: '财务咨询', desc: '提供财务规划和优化建议' },
|
||||||
|
{ icon: Shield, title: '风险咨询', desc: '识别和控制企业经营风险' },
|
||||||
|
],
|
||||||
|
process: [
|
||||||
|
{ step: '01', title: '现状诊断', desc: '全面评估企业现状' },
|
||||||
|
{ step: '02', title: '问题分析', desc: '深入分析核心问题' },
|
||||||
|
{ step: '03', title: '方案设计', desc: '制定针对性解决方案' },
|
||||||
|
{ step: '04', title: '落地实施', desc: '协助方案落地和效果跟踪' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 成功案例数据
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '某大型制造企业数字化转型',
|
||||||
|
category: 'tech',
|
||||||
|
description: '帮助某大型制造企业完成全面的数字化转型,实现生产效率提升 30%,成本降低 20%。',
|
||||||
|
result: ['生产效率提升 30%', '成本降低 20%', '库存周转率提升 40%'],
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '新兴产业基金设立与运营',
|
||||||
|
category: 'investment',
|
||||||
|
description: '为某地方政府设立 50 亿元新兴产业投资基金,成功投资 20 余个优质项目。',
|
||||||
|
result: ['基金规模 50 亿元', '投资项目 20+', '平均回报率 25%'],
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '企业战略重组咨询项目',
|
||||||
|
category: 'consulting',
|
||||||
|
description: '为某上市公司提供战略重组咨询服务,成功完成业务整合,实现市值增长 50%。',
|
||||||
|
result: ['市值增长 50%', '完成 3 家公司整合', '营收增长 35%'],
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '高净值客户财富管理',
|
||||||
|
category: 'finance',
|
||||||
|
description: '为某高净值客户提供全方位的财富管理服务,5 年累计收益达到 120%。',
|
||||||
|
result: ['累计收益 120%', '资产规模增长 80%', '零重大风险事件'],
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合作伙伴数据
|
||||||
|
const partners = [
|
||||||
|
{ name: '中国银行', industry: '金融' },
|
||||||
|
{ name: '华为技术', industry: '科技' },
|
||||||
|
{ name: '中信证券', industry: '金融' },
|
||||||
|
{ name: '阿里巴巴', industry: '科技' },
|
||||||
|
{ name: '招商银行', industry: '金融' },
|
||||||
|
{ name: '腾讯云', industry: '科技' },
|
||||||
|
{ name: '建设银行', industry: '金融' },
|
||||||
|
{ name: '字节跳动', industry: '科技' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 分类标签
|
||||||
|
const categories = [
|
||||||
|
{ id: 'all', label: '全部' },
|
||||||
|
{ id: 'finance', label: '金融服务' },
|
||||||
|
{ id: 'tech', label: '科技研发' },
|
||||||
|
{ id: 'investment', label: '产业投资' },
|
||||||
|
{ id: 'consulting', label: '咨询服务' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功案例卡片组件
|
||||||
|
*/
|
||||||
|
const CaseCard: React.FC<{ case: typeof cases[0]; index: number }> = ({ case: caseItem, index }) => (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-lg transition-shadow"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
{/* 案例图片 */}
|
||||||
|
<div className="aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/5 to-primary-light/10">
|
||||||
|
<img
|
||||||
|
src={`https://images.unsplash.com/photo-${
|
||||||
|
caseItem.category === 'tech' ? '1518770660439-4636190af475' :
|
||||||
|
caseItem.category === 'investment' ? '1460925895917-afdab827c52f' :
|
||||||
|
caseItem.category === 'consulting' ? '1552664730-d307ca884978' :
|
||||||
|
'1551288049-1bf847840e16'
|
||||||
|
}?w=800&h=450&fit=crop`}
|
||||||
|
alt={caseItem.title}
|
||||||
|
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<span className="inline-block px-3 py-1 text-xs font-medium bg-accent/20 text-accent-dark rounded-full mb-3">
|
||||||
|
{categories.find(c => c.id === caseItem.category)?.label}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-semibold text-primary-dark mb-3">
|
||||||
|
{caseItem.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">{caseItem.description}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{caseItem.result.map((result, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<CheckCircle size={14} className="text-accent flex-shrink-0" />
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services 组件
|
||||||
|
*/
|
||||||
|
export const Services: React.FC = () => {
|
||||||
|
usePageTitle('产品服务');
|
||||||
|
const [activeCategory, setActiveCategory] = useState('all');
|
||||||
|
|
||||||
|
const filteredCases = activeCategory === 'all'
|
||||||
|
? cases
|
||||||
|
: cases.filter(c => c.category === activeCategory);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="min-h-screen bg-background"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* 主内容 */}
|
||||||
|
<main>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<section className="pt-32 pb-16 bg-gradient-to-br from-primary to-primary-light">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center text-white"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
产品服务
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-white/80 max-w-3xl mx-auto">
|
||||||
|
为客户提供全方位的专业服务解决方案
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 服务概览 */}
|
||||||
|
<section className="py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
核心业务板块
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
四大核心业务,构建全方位服务体系
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 服务卡片 */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{SERVICES.map((service, index) => {
|
||||||
|
const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
|
TrendingUp,
|
||||||
|
Cpu,
|
||||||
|
Building2,
|
||||||
|
Briefcase,
|
||||||
|
};
|
||||||
|
const Icon = iconMap[service.icon] || TrendingUp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={service.id}
|
||||||
|
className="group p-8 bg-white rounded-2xl shadow-sm border border-gray-100 hover:shadow-lg transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -8 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary-light text-white mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<Icon size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-primary-dark mb-4">
|
||||||
|
{service.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed mb-6">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{service.features.map((feature, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<CheckCircle size={14} className="text-accent" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 详细服务介绍 - 使用 Tab 切换 */}
|
||||||
|
<section className="py-20 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
服务详情
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
深入了解我们的专业服务
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 使用静态展示代替 Tab - 展示金融服务 */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 items-center mb-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<span className="inline-block px-4 py-1 bg-accent/20 text-accent-dark text-sm font-semibold rounded-full mb-4">
|
||||||
|
金融服务
|
||||||
|
</span>
|
||||||
|
<h3 className="text-2xl font-bold text-primary-dark mb-6">
|
||||||
|
专业财富管理解决方案
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 leading-relaxed mb-8">
|
||||||
|
{serviceDetails.finance.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 特性列表 */}
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4 mb-8">
|
||||||
|
{serviceDetails.finance.features.map((feature, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-3 p-4 bg-background rounded-xl">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<feature.icon size={20} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-primary-dark text-sm">
|
||||||
|
{feature.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{feature.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="aspect-square rounded-2xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1554224311-beee460ae6ba?w=800&h=800&fit=crop"
|
||||||
|
alt="金融服务 - 数据分析与财富管理"
|
||||||
|
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 科技研发 */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 items-center mb-20">
|
||||||
|
<motion.div
|
||||||
|
className="order-2 lg:order-1"
|
||||||
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="aspect-square rounded-2xl overflow-hidden shadow-xl">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&h=800&fit=crop"
|
||||||
|
alt="科技研发 - 数字化转型与创新"
|
||||||
|
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="order-1 lg:order-2"
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<span className="inline-block px-4 py-1 bg-primary/10 text-primary text-sm font-semibold rounded-full mb-4">
|
||||||
|
科技研发
|
||||||
|
</span>
|
||||||
|
<h3 className="text-2xl font-bold text-primary-dark mb-6">
|
||||||
|
数字化转型专家
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 leading-relaxed mb-8">
|
||||||
|
{serviceDetails.tech.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4 mb-8">
|
||||||
|
{serviceDetails.tech.features.map((feature, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-3 p-4 bg-background rounded-xl">
|
||||||
|
<div className="p-2 bg-accent/20 rounded-lg">
|
||||||
|
<feature.icon size={20} className="text-accent-dark" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-primary-dark text-sm">
|
||||||
|
{feature.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{feature.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 成功案例 */}
|
||||||
|
<section className="py-20 bg-background">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-12"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-2">
|
||||||
|
成功案例
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
真实项目,展现专业实力
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选标签 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.slice(0, 5).map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
onClick={() => setActiveCategory(category.id)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||||
|
activeCategory === category.id
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 案例网格 */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-2 gap-8">
|
||||||
|
{filteredCases.map((caseItem, index) => (
|
||||||
|
<CaseCard key={caseItem.id} case={caseItem} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 合作伙伴 */}
|
||||||
|
<section className="py-20 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-primary-dark mb-4">
|
||||||
|
合作伙伴
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
与众多知名企业建立深度合作关系
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{partners.map((partner, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={partner.name}
|
||||||
|
className="p-6 bg-background rounded-xl hover:shadow-md transition-shadow flex flex-col items-center"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-lg overflow-hidden mb-4 bg-gradient-to-br from-primary/10 to-primary-light/10 flex items-center justify-center">
|
||||||
|
<Building2 size={32} className="text-primary opacity-50" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-primary-dark">
|
||||||
|
{partner.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500">{partner.industry}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 服务流程 */}
|
||||||
|
<section className="py-20 bg-primary-dark text-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-16"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold mb-4">服务流程</h2>
|
||||||
|
<p className="text-lg text-gray-300">
|
||||||
|
标准化流程,确保服务质量
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
{serviceDetails.finance.process.map((step, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={step.step}
|
||||||
|
className="text-center"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent text-primary-dark font-bold text-xl mb-6">
|
||||||
|
{step.step}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-3">{step.title}</h3>
|
||||||
|
<p className="text-gray-400 text-sm">{step.desc}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Footer />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Services;
|
||||||
36
tailwind.config.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// 企业主题色
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#1e3a8a', // 深蓝色 - 主色调
|
||||||
|
light: '#2563eb', // 浅蓝色 - hover 状态
|
||||||
|
dark: '#1e293b', // 深色 - 文字
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#d4af37', // 金色 - 辅助色
|
||||||
|
light: '#e5c158', // 浅金色 - hover
|
||||||
|
dark: '#b8960c', // 深金色
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
DEFAULT: '#f8fafc', // 浅灰色 - 背景色
|
||||||
|
light: '#ffffff', // 白色
|
||||||
|
dark: '#f1f5f9', // 深浅灰
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
heading: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
|
],
|
||||||
|
}
|
||||||
28
tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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": false,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
14
vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
strictPort: true,
|
||||||
|
port: 3000,
|
||||||
|
allowedHosts: true
|
||||||
|
},
|
||||||
|
});
|
||||||