Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c9f2276f2 | |||
| 8840c4398a | |||
| f08874f5c4 | |||
| c474394237 | |||
| 0d517078ad | |||
| 27d486d820 | |||
| 37296b5717 |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -1 +1,10 @@
|
|||||||
|
# Google Analytics (生产环境中应配置真实值)
|
||||||
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||||
|
|
||||||
|
# Sentry 错误监控 (免费版 - https://sentry.io/signup)
|
||||||
|
# 获取方式: 登录 sentry.io → 创建项目 → Settings → Client Keys (DSN)
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||||
|
SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||||
|
|
||||||
|
# CDN 配置(可选)
|
||||||
|
CDN_DOMAIN=
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ develop-eggs/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
!src/lib/
|
||||||
lib/
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Novalon Website - 领域术语表
|
||||||
|
|
||||||
|
## 核心实体
|
||||||
|
|
||||||
|
### 墨韵流光
|
||||||
|
Novalon 网站的核心视觉系统,包含两个子机制:
|
||||||
|
- **旋转渐变边框 (ink-glow-border)**:卡片边框使用旋转的 conic-gradient,产生墨韵流动感
|
||||||
|
- **鼠标跟随光晕 (mouse-follow)**:卡片内跟随鼠标的 radial-gradient 光晕,产生交互反馈
|
||||||
|
|
||||||
|
当前状态:.impeccable.md 已定义规范,但首页组件**未实现**。
|
||||||
|
|
||||||
|
### 水墨雅致
|
||||||
|
Novalon 的整体设计风格定位。核心原则:以留白和排版取胜,特效点到为止。参考 Apple 中国官网。
|
||||||
|
|
||||||
|
### 朱砂点睛
|
||||||
|
品牌红 #C41E3A 的使用原则——仅作点缀,不作为主色调。标题中关键词用 font-calligraphy 突出。
|
||||||
|
|
||||||
|
### 特效组件 (Effects)
|
||||||
|
`src/components/effects/` 目录下的 24 个视觉特效组件。当前状态:**大部分未被首页使用**,属于技术债务。
|
||||||
|
|
||||||
|
### Design Tokens
|
||||||
|
`.impeccable.md` 中定义的设计令牌系统,包含颜色、排版、间距、卡片系统、Section 背景交替规则。当前状态:**文档已定义,代码中 globals.css 有对应 CSS 变量,但组件层未完全落地**。
|
||||||
|
|
||||||
|
## 关键决策
|
||||||
|
|
||||||
|
| 术语 | 含义 | 决策状态 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 重构范围 | 保持 5 个 section 不变,聚焦质量提升 | ✅ 已确认 |
|
||||||
|
| 特效取舍 | 逐个评估 24 个特效组件,决定保留/改造/删除 | ✅ 已确认 |
|
||||||
|
| Hero 视觉方向 | 先用原型对比再决定(排版驱动 vs 墨韵背景 vs 中间路线) | ✅ 已确认 |
|
||||||
|
| web-design-engineer 定位 | 仅用于 Hero 原型验证,不用于全站重构 | ✅ 已确认 |
|
||||||
|
| 运营状态 | 未正式上线,重构风险可控 | ✅ 已确认 |
|
||||||
|
|
||||||
|
## 歧义已解决
|
||||||
|
|
||||||
|
- **"重构"≠ 推倒重来**:在现有 Next.js 架构内做系统性清理和提升,不更换技术栈
|
||||||
|
- **"web-design-engineer"≠ 代码生成器**:它是设计验证工具,产出原型 HTML,不是最终代码
|
||||||
|
- **"克制动效"≠ 零动效**:动效服务于信息传达(hover 反馈、scroll reveal、卡片交互),但不做装饰性粒子/水墨动画
|
||||||
Vendored
+388
@@ -0,0 +1,388 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
NODE_VERSION = '18'
|
||||||
|
NPM_REGISTRY = 'https://registry.npmmirror.com'
|
||||||
|
PROJECT_NAME = 'novalon-website'
|
||||||
|
SERVER_IP = '139.155.109.62'
|
||||||
|
SERVER_USER = 'root'
|
||||||
|
DEPLOY_ROOT = '/home/novalon/docker-app'
|
||||||
|
STATIC_DIR = 'novalon-static'
|
||||||
|
NGINX_CONTAINER = 'novalon-nginx-secure'
|
||||||
|
DOMAIN = 'https://novalon.cn'
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||||
|
timeout(time: 30, unit: 'MINUTES')
|
||||||
|
timestamps()
|
||||||
|
ansiColor('xterm')
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers {
|
||||||
|
// Gitea Webhook 触发
|
||||||
|
GenericTrigger(
|
||||||
|
genericVariables: [
|
||||||
|
[key: 'ref', value: '$.ref'],
|
||||||
|
[key: 'repository', value: '$.repository.full_name'],
|
||||||
|
[key: 'commit_sha', value: '$.after'],
|
||||||
|
[key: 'commit_message', value: '$.commits[0].message']
|
||||||
|
],
|
||||||
|
token: '${PROJECT_NAME}-ci-token',
|
||||||
|
causeString: 'Triggered by $ref on $repository',
|
||||||
|
printContributedVariables: true,
|
||||||
|
printPostContent: true,
|
||||||
|
silentResponse: false,
|
||||||
|
regexpFilterText: '$ref$repository',
|
||||||
|
regexpFilterExpression: '^(refs/heads/main|refs/heads/develop).*$'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 每天凌晨 3 点定时检查依赖安全漏洞
|
||||||
|
pollSCM('H 3 * * *')
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
booleanParam(
|
||||||
|
defaultValue: false,
|
||||||
|
description: '是否部署到生产环境(仅在 main 分支且测试通过后可用)',
|
||||||
|
name: 'DEPLOY_TO_PRODUCTION'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('🔧 环境准备') {
|
||||||
|
steps {
|
||||||
|
echo "📦 Node.js 版本: ${NODE_VERSION}"
|
||||||
|
echo "🌐 目标域名: ${DOMAIN}"
|
||||||
|
echo "🖥️ 生产服务器: ${SERVER_USER}@${SERVER_IP}"
|
||||||
|
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh 'node --version'
|
||||||
|
sh 'npm --version'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('📥 安装依赖') {
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm ci --registry=${NPM_REGISTRY} --prefer-offline
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
failure {
|
||||||
|
echo "❌ 依赖安装失败!请检查 npm registry 是否可用。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('🔍 代码质量检查') {
|
||||||
|
parallel {
|
||||||
|
stage('ESLint 检查') {
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh 'npm run lint'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('TypeScript 类型检查') {
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh 'npm run type-check'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
failure {
|
||||||
|
echo "❌ 代码质量检查未通过!"
|
||||||
|
echo "💡 提示:请运行 npm run lint 和 npm run type-check 在本地修复问题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('🧪 单元测试 + 覆盖率检查') {
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
export CI=true
|
||||||
|
npm run test:coverage:check
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
publishHTML(target: [
|
||||||
|
allowMissing: false,
|
||||||
|
alwaysLinkToLastBuild: true,
|
||||||
|
keepAll: true,
|
||||||
|
reportDir: 'coverage',
|
||||||
|
reportFiles: 'index.html',
|
||||||
|
reportName: 'Coverage Report (Jest)'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "✅ 单元测试通过!覆盖率 ≥ 80%"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "❌ 单元测试失败或覆盖率不足 80%!"
|
||||||
|
echo "💡 请查看 Coverage Report 了解详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('🏗️ 生产构建') {
|
||||||
|
when {
|
||||||
|
anyOf {
|
||||||
|
branch 'main'
|
||||||
|
branch 'develop'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
rm -rf .next dist
|
||||||
|
npm run build:clean
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
FILE_COUNT=$(find dist -type f | wc -l)
|
||||||
|
DIST_SIZE=$(du -sh dist | cut -f1)
|
||||||
|
echo "✅ 构建成功!生成了 $FILE_COUNT 个文件,总大小 $DIST_SIZE"
|
||||||
|
else
|
||||||
|
echo "❌ 构建失败:dist 目录不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
archiveArtifacts artifacts: 'dist/**', fingerprint: true
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "❌ Next.js 构建失败!请检查构建日志中的错误信息。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('🌐 E2E 测试') {
|
||||||
|
when {
|
||||||
|
allOf {
|
||||||
|
anyOf {
|
||||||
|
branch 'main'
|
||||||
|
branch 'develop'
|
||||||
|
}
|
||||||
|
expression { return params.DEPLOY_TO_PRODUCTION == true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
cd e2e && npx playwright test --config=playwright.config.ts || exit 1
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
publishHTML(target: [
|
||||||
|
allowMissing: false,
|
||||||
|
alwaysLinkToLastBuild: true,
|
||||||
|
keepAll: true,
|
||||||
|
reportDir: 'e2e/playwright-report',
|
||||||
|
reportFiles: 'index.html',
|
||||||
|
reportName: 'E2E Test Report (Playwright)'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "✅ E2E 测试全部通过!"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "❌ E2E 测试失败!"
|
||||||
|
echo "💡 请查看 E2E Test Report 了解哪些用例未通过"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('🚀 部署到生产环境') {
|
||||||
|
when {
|
||||||
|
allOf {
|
||||||
|
branch 'main'
|
||||||
|
expression { return params.DEPLOY_TO_PRODUCTION == true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
echo "⚠️ 即将部署到生产环境:${DOMAIN}"
|
||||||
|
sleep(time: 5, unit: 'SECONDS')
|
||||||
|
|
||||||
|
sshagent(['deploy-server-ssh-key']) {
|
||||||
|
sh """
|
||||||
|
# 备份旧版本
|
||||||
|
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} '
|
||||||
|
set -e
|
||||||
|
STATIC_PATH=\"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||||
|
BACKUP_PATH=\"${DEPLOY_ROOT}/${STATIC_DIR}_backup_\$(date +%Y%m%d_%H%M%S)\"
|
||||||
|
|
||||||
|
if [ -d \"\$STATIC_PATH\" ]; then
|
||||||
|
echo \"📦 备份旧版本到 \$BACKUP_PATH\"
|
||||||
|
cp -r \"\$STATIC_PATH\" \"\$BACKUP_PATH\"
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
|
||||||
|
# 上传新版本
|
||||||
|
echo "📤 上传 dist 目录到服务器..."
|
||||||
|
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} \
|
||||||
|
\"mkdir -p ${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||||
|
|
||||||
|
rsync -avz --delete \
|
||||||
|
dist/ \
|
||||||
|
${SERVER_USER}@${SERVER_IP}:${DEPLOY_ROOT}/${STATIC_DIR}/
|
||||||
|
|
||||||
|
# 设置权限并重载 Nginx
|
||||||
|
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} '
|
||||||
|
set -e
|
||||||
|
STATIC_PATH=\"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||||
|
chown -R www-data:www-data \"\$STATIC_PATH\" 2>/dev/null || true
|
||||||
|
chmod -R 755 \"\$STATIC_PATH\"
|
||||||
|
docker exec ${NGINX_CONTAINER} nginx -s reload
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证部署成功
|
||||||
|
sh """
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
HTTP_STATUS=\$(curl -s -o /dev/null -w '%{http_code}' ${DOMAIN})
|
||||||
|
if [ "\$HTTP_STATUS" = "200" ]; then
|
||||||
|
echo "✅ 部署验证成功!HTTP 状态码: \$HTTP_STATUS"
|
||||||
|
else
|
||||||
|
echo "❌ 部署验证失败!HTTP 状态码: \$HTTP_STATUS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查关键页面
|
||||||
|
for PAGE in "/" "/contact" "/about" "/products"; do
|
||||||
|
STATUS=\$(curl -s -o /dev/null -w '%{http_code}' ${DOMAIN}\$PAGE)
|
||||||
|
if [ "\$STATUS" != "200" ]; then
|
||||||
|
echo "⚠️ 页面 \$PAGE 返回状态码 \$STATUS"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 部署完成!访问地址: ${DOMAIN}"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
script {
|
||||||
|
def commitMsg = sh(
|
||||||
|
script: 'git log -1 --pretty=%B',
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
def buildUrl = env.BUILD_URL
|
||||||
|
def buildNum = env.BUILD_NUMBER
|
||||||
|
|
||||||
|
// 发送部署成功通知(钉钉/企业微信)
|
||||||
|
echo """
|
||||||
|
🎉 **Novalon Website 部署成功**
|
||||||
|
|
||||||
|
- **构建号**: #${buildNum}
|
||||||
|
- **提交信息**: ${commitMsg}
|
||||||
|
- **目标域名**: ${DOMAIN}
|
||||||
|
- **构建详情**: ${buildUrl}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "❌ 部署失败!正在执行自动回滚..."
|
||||||
|
|
||||||
|
// 自动回滚到上一个备份
|
||||||
|
sshagent(['deploy-server-ssh-key']) {
|
||||||
|
sh """
|
||||||
|
ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} '
|
||||||
|
set -e
|
||||||
|
BACKUP_DIR=\"${DEPLOY_ROOT}\"
|
||||||
|
LATEST_BACKUP=\$(ls -dt \${BACKUP_DIR}/${STATIC_DIR}_backup_* 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n \"\$LATEST_BACKUP\" ]; then
|
||||||
|
echo \"🔄 回滚到最近备份: \$LATEST_BACKUP\"
|
||||||
|
rm -rf \"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||||
|
cp -r \"\$LATEST_BACKUP\" \"${DEPLOY_ROOT}/${STATIC_DIR}\"
|
||||||
|
docker exec ${NGINX_CONTAINER} nginx -s reload
|
||||||
|
echo "✅ 回滚完成"
|
||||||
|
else
|
||||||
|
echo "❌ 未找到可用的备份!需要手动介入"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('🧹 清理工作空间') {
|
||||||
|
steps {
|
||||||
|
cleanWs(cleanWhenNotBuilt: false,
|
||||||
|
deleteDirs: true,
|
||||||
|
notFailBuild: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
script {
|
||||||
|
def result = currentBuild.result ?: 'SUCCESS'
|
||||||
|
def duration = currentBuild.durationString.replace(' and counting', '')
|
||||||
|
def color = result == 'SUCCESS' ? 'good' : (result == 'UNSTABLE' ? 'warning' : 'danger')
|
||||||
|
|
||||||
|
echo """
|
||||||
|
====================================
|
||||||
|
📊 Jenkins 构建报告
|
||||||
|
====================================
|
||||||
|
- **项目**: ${env.JOB_NAME}
|
||||||
|
- **构建号**: #${env.BUILD_NUMBER}
|
||||||
|
- **结果**: ${result}
|
||||||
|
- **耗时**: ${duration}
|
||||||
|
- **详情**: ${env.BUILD_URL}
|
||||||
|
====================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
// 发送通知(可根据需要集成钉钉、企业微信、邮件等)
|
||||||
|
if (result != 'SUCCESS') {
|
||||||
|
mail (
|
||||||
|
to: 'dev-team@novalon.cn',
|
||||||
|
subject: "❌ Novalon Website 构建失败: #${env.BUILD_NUMBER}",
|
||||||
|
body: """构建结果: ${result}
|
||||||
|
|
||||||
|
请尽快查看构建详情:
|
||||||
|
${env.BUILD_URL}
|
||||||
|
|
||||||
|
构建日志:
|
||||||
|
${env.BUILD_URL}console"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success {
|
||||||
|
echo "✅ Pipeline 执行成功!"
|
||||||
|
}
|
||||||
|
|
||||||
|
failure {
|
||||||
|
echo "❌ Pipeline 执行失败!请检查上述错误信息。"
|
||||||
|
}
|
||||||
|
|
||||||
|
aborted {
|
||||||
|
echo "⚠️ Pipeline 被中止!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
config/lint/babel.config.js
|
|
||||||
@@ -43,17 +43,20 @@
|
|||||||
"react/no-unescaped-entities": "error",
|
"react/no-unescaped-entities": "error",
|
||||||
"react/jsx-no-target-blank": "error",
|
"react/jsx-no-target-blank": "error",
|
||||||
"react/self-closing-comp": "error",
|
"react/self-closing-comp": "error",
|
||||||
|
"react/display-name": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", {
|
"@typescript-eslint/no-unused-vars": ["error", {
|
||||||
"argsIgnorePattern": "^_",
|
"argsIgnorePattern": "^_",
|
||||||
"varsIgnorePattern": "^_"
|
"varsIgnorePattern": "^_"
|
||||||
}],
|
}],
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-empty-object-type": "off",
|
||||||
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-var": "error",
|
"no-var": "error",
|
||||||
"eqeqeq": ["error", "always"],
|
"eqeqeq": ["error", "always"],
|
||||||
"curly": ["error", "all"],
|
"curly": ["error", "multi-line"],
|
||||||
"no-throw-literal": "error",
|
"no-throw-literal": "error",
|
||||||
"prefer-promise-reject-errors": "error"
|
"prefer-promise-reject-errors": "error",
|
||||||
|
"react-hooks/set-state-in-effect": "warn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ADR 0001: 重构路径选择——混合方案而非全站 web-design-engineer 替换
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
已接受
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
Novalon 网站面临技术债务问题:24 个特效组件和 50+ UI 组件大部分未被首页使用,.impeccable.md 定义的设计系统未完全落地,globals.css 有 1200+ 行但大量未引用。需要决定重构路径。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
采用**混合方案**:web-design-engineer 仅用于 Hero 区原型验证,主体重构在现有 Next.js 架构内进行。
|
||||||
|
|
||||||
|
## 理由
|
||||||
|
|
||||||
|
### 为什么不用 web-design-engineer 全站替换?
|
||||||
|
|
||||||
|
1. **架构不兼容**:web-design-engineer 产出独立 HTML 文件,无法集成到 Next.js App Router + 静态导出架构中
|
||||||
|
2. **丢失现有资产**:50+ UI 组件(含测试)、SEO 优化、分析追踪、可访问性适配等将全部丢失
|
||||||
|
3. **维护性倒退**:单文件 HTML 无法支撑后续迭代(产品页、解决方案页等动态路由)
|
||||||
|
|
||||||
|
### 为什么用混合方案?
|
||||||
|
|
||||||
|
1. **Hero 视觉方向未定**:当前 Hero 是纯白底+文字,与设计文档差距最大,需要原型对比来决策
|
||||||
|
2. **web-design-engineer 擅长快速视觉探索**:2-3 个 Hero 变体可在单次会话中完成
|
||||||
|
3. **主体工作在 Next.js 内更高效**:清理死代码、激活设计系统、整合特效——这些都需要在代码库内操作
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
- 正面:保留现有架构和资产,风险可控;Hero 原型可快速验证视觉方向
|
||||||
|
- 负面:Hero 原型需要"翻译"为 Next.js 组件,存在少量双工
|
||||||
|
- 风险:如果 Hero 原型方向与现有设计系统冲突,可能需要调整 .impeccable.md
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
# Novalon Website 回滚流程指南(生产环境专用)
|
||||||
|
|
||||||
|
> **最后更新**: 2026-05-12
|
||||||
|
> **适用项目**: novalon-website (四川睿新致远科技有限公司官网)
|
||||||
|
> **部署架构**: 静态导出 + Docker + Nginx + CDN
|
||||||
|
> **目标恢复时间**: < 10 分钟(P0 级别故障)
|
||||||
|
>
|
||||||
|
> **⚠️ 重要提示**:本文档已针对你的实际部署环境定制,请勿使用通用模板!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 你的实际部署架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 开发机 (Local) │
|
||||||
|
│ - 项目路径: /Users/zhangxiang/Codes/Novalon/ │
|
||||||
|
│ novalon-website │
|
||||||
|
│ - 构建命令: npm run build:clean │
|
||||||
|
│ - 输出目录: dist/ │
|
||||||
|
│ - 部署命令: ./deploy-dist.sh │
|
||||||
|
└──────────────────────┬──────────────────────────────┘
|
||||||
|
│ rsync + SSH (root@139.155.109.62)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 生产服务器 (139.155.109.62) │
|
||||||
|
│ └── /home/novalon/docker-app/ │
|
||||||
|
│ ├── novalon-static/ ← 当前版本 │
|
||||||
|
│ ├── novalon-static_backup_* ← 自动备份 │
|
||||||
|
│ └── docker-compose.yml │
|
||||||
|
│ └── novalon-nginx-secure ← Nginx 容器 │
|
||||||
|
└──────────────────────┬──────────────────────────────┘
|
||||||
|
│ 反向代理
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 用户访问 │
|
||||||
|
│ - 主域名: https://novalon.cn │
|
||||||
|
│ - 备用 IP: https://139.155.109.62 │
|
||||||
|
│ - HTTP → HTTPS 自动跳转 │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 **触发条件(满足任一即启动回滚)**
|
||||||
|
|
||||||
|
### **P0 - 紧急故障(立即回滚,无需审批)**
|
||||||
|
- [ ] 首页完全白屏或返回 500/502/503/504
|
||||||
|
- [ ] 所有页面无法访问(HTTP 状态码非 200)
|
||||||
|
- [ ] 安全漏洞报告(XSS、数据泄露、恶意重定向)
|
||||||
|
- [ ] 域名解析失败或 SSL 证书过期
|
||||||
|
|
||||||
|
### **P1 - 严重问题(30 分钟内回滚)**
|
||||||
|
- [ ] 核心功能不可用:
|
||||||
|
- 导航栏无法点击
|
||||||
|
- 表单提交失败(联系表单、订阅等)
|
||||||
|
- 主题切换失效
|
||||||
|
- 移动端布局错乱
|
||||||
|
- [ ] 关键 SEO 元数据丢失:
|
||||||
|
- `<title>` 为空或显示 "Untitled"
|
||||||
|
- `<meta name="description">` 缺失
|
||||||
|
- Open Graph / Twitter Card 标签缺失
|
||||||
|
- [ ] 性能严重退化:
|
||||||
|
- Lighthouse Performance < 50(之前 > 90)
|
||||||
|
- LCP (Largest Contentful Paint) > 5 秒
|
||||||
|
- FID (First Input Delay) > 300ms
|
||||||
|
|
||||||
|
### **P2 - 一般问题(24 小时内修复)**
|
||||||
|
- [ ] 次要页面样式异常(不影响核心功能)
|
||||||
|
- [ ] 图片资源加载失败(非首屏图片)
|
||||||
|
- [ ] 控制台有警告但无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 **Type 1:代码回滚(最常用,推荐首选)**
|
||||||
|
|
||||||
|
### **适用场景**
|
||||||
|
- 最近一次部署引入了 bug
|
||||||
|
- 需要快速恢复到上一个稳定版本
|
||||||
|
- Git 历史清晰,可以定位到稳定的 commit
|
||||||
|
|
||||||
|
#### **Step 0:准备阶段(2 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0.1 确认当前状态
|
||||||
|
echo "=== 当前时间 ==="
|
||||||
|
date '+%Y-%m-%d %H:%M:%S'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 当前 Git 分支和 Commit ==="
|
||||||
|
git branch --show-current
|
||||||
|
git log --oneline -3
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 检查是否有未提交的更改 ==="
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出示例**:
|
||||||
|
```
|
||||||
|
=== 当前时间 ===
|
||||||
|
2026-05-12 15:30:00
|
||||||
|
|
||||||
|
=== 当前 Git 分支和 Commit ===
|
||||||
|
main
|
||||||
|
abc1234 (HEAD -> main) feat: update hero animation
|
||||||
|
def5678 fix: improve mobile layout
|
||||||
|
ghi9012 chore: upgrade dependencies
|
||||||
|
|
||||||
|
=== 检查是否有未提交的更改 ===
|
||||||
|
M src/components/Hero.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Step 1:确认问题现象(2 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1.1 检查网站是否可访问
|
||||||
|
curl -I https://novalon.cn/ | head -10
|
||||||
|
|
||||||
|
# 预期输出: HTTP/2 200
|
||||||
|
# 如果看到 500/502/503/504,说明需要紧急回滚
|
||||||
|
|
||||||
|
# 1.2 检查关键页面的 HTTP 状态码
|
||||||
|
for page in "/" "/about" "/contact" "/products" "/solutions"; do
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" "https://novalon.cn${page}")
|
||||||
|
echo "$page -> $status"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 预期输出: 所有页面都返回 200
|
||||||
|
|
||||||
|
# 1.3 检查关键 HTML 元素是否存在
|
||||||
|
curl -s https://novalon.cn/ | grep -E '<title>|<meta name="description">|<h1'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出示例**:
|
||||||
|
```
|
||||||
|
/ -> 200
|
||||||
|
/about -> 200
|
||||||
|
/contact -> 200
|
||||||
|
/products -> 200
|
||||||
|
/solutions -> 200
|
||||||
|
|
||||||
|
<title>四川睿新致远科技有限公司</title>
|
||||||
|
<meta name="description" content="..." />
|
||||||
|
<h1 class="...">...</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
如果上述检查有任何一项不通过,**立即进入 Step 2**。
|
||||||
|
|
||||||
|
#### **Step 2:定位稳定版本(3 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 2.1 查看 Git 历史,找到最近的稳定版本
|
||||||
|
git log --oneline --graph -20
|
||||||
|
|
||||||
|
# 寻找标记为 "stable"、"fix:"、"chore:" 的 commit
|
||||||
|
# 或者查看 Git Tags
|
||||||
|
git tag -l 'v*' --sort=-v:refname | head -5
|
||||||
|
|
||||||
|
# 如果没有 Tag,根据 commit message 判断
|
||||||
|
# 推荐选择最近一次包含以下关键词的 commit:
|
||||||
|
# - "fix:" (bug 修复)
|
||||||
|
# - "release:" 或 "v*.*.*" (正式发布)
|
||||||
|
# - "chore: bump version" (版本号更新)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例输出**:
|
||||||
|
```
|
||||||
|
* abc1234 (HEAD -> main) feat: update hero animation ← 当前版本(有问题)
|
||||||
|
* def5678 fix: improve mobile layout ← 可能是稳定版本
|
||||||
|
* ghi9012 chore: upgrade dependencies ← 上一个版本
|
||||||
|
* jkl3456 release: v1.0.0-stable ← 正式发布版(最安全)
|
||||||
|
```
|
||||||
|
|
||||||
|
**决策建议**:
|
||||||
|
- 如果 `def5678` 是你上次验证过的版本,回滚到它
|
||||||
|
- 如果不确定,选择 `jkl3456` (Tagged release)
|
||||||
|
|
||||||
|
#### **Step 3:执行本地代码回滚(3 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 3.1 备份当前版本(以防万一)
|
||||||
|
git stash push -m "backup-before-rollback-$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|
||||||
|
# 或者创建一个新的分支保存当前代码
|
||||||
|
git branch backup/broken-version-$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# 3.2 切换到稳定版本
|
||||||
|
# 方法 A:使用 Git Tag(推荐)
|
||||||
|
git checkout v1.0.0-stable
|
||||||
|
|
||||||
|
# 方法 B:使用 Commit Hash
|
||||||
|
git checkout def5678
|
||||||
|
|
||||||
|
# 3.3 确认已切换成功
|
||||||
|
git log --oneline -1
|
||||||
|
# 应该显示: def5678 fix: improve mobile layout
|
||||||
|
|
||||||
|
echo "✅ 已切换到稳定版本"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Step 4:重新构建并部署(5 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 4.1 清理并构建
|
||||||
|
npm run build:clean
|
||||||
|
|
||||||
|
# 如果构建成功,会看到类似输出:
|
||||||
|
# ✅ Built in Xs
|
||||||
|
# 📁 生成了 XXX 个文件,总大小: XX MB
|
||||||
|
|
||||||
|
# 4.2 快速验证构建产物
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
echo "✅ dist 目录存在"
|
||||||
|
|
||||||
|
# 检查关键文件是否存在
|
||||||
|
ls -lh dist/index.html
|
||||||
|
ls -lh dist/about/index.html
|
||||||
|
ls -lh dist/contact/index.html
|
||||||
|
|
||||||
|
# 检查 HTML 内容
|
||||||
|
head -20 dist/index.html | grep '<title>'
|
||||||
|
else
|
||||||
|
echo "❌ 构建失败!dist 目录不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4.3 部署到生产环境
|
||||||
|
./deploy-dist.sh
|
||||||
|
|
||||||
|
# 脚本会自动执行:
|
||||||
|
# 1. 检查 dist 目录
|
||||||
|
# 2. 验证 SSH 连接
|
||||||
|
# 3. 备份旧版本(在服务器上)
|
||||||
|
# 4. 上传新的 dist
|
||||||
|
# 5. 设置权限
|
||||||
|
# 6. 重载 Nginx
|
||||||
|
# 7. 验证部署结果
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Step 5:验证回滚成功(2 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5.1 等待 3-5 秒让 CDN 缓存刷新
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 5.2 再次执行 Step 1.2 和 1.3 的检查
|
||||||
|
for page in "/" "/about" "/contact" "/products"; do
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" "https://novalon.cn${page}")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
echo "✅ $page -> $status"
|
||||||
|
else
|
||||||
|
echo "❌ $page -> $status (需要进一步排查)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5.3 浏览器手动验证(重要!)
|
||||||
|
open https://novalon.cn/
|
||||||
|
|
||||||
|
# 手动检查清单:
|
||||||
|
# □ 首页正常加载,无白屏
|
||||||
|
# □ 导航栏可点击,所有链接正常工作
|
||||||
|
# □ Logo 显示正确(应该是书法字体)
|
||||||
|
# □ 主题切换按钮可用(如果有暗色模式)
|
||||||
|
# □ 移动端布局正常(可以用 Chrome DevTools 模拟)
|
||||||
|
# □ 表单可以打开(即使不提交)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Step 6:通知相关人员(2 分钟)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 6.1 记录回滚操作日志
|
||||||
|
cat << EOF >> /tmp/rollback-log.txt
|
||||||
|
==========================================
|
||||||
|
回滚记录
|
||||||
|
==========================================
|
||||||
|
时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
操作人: $(whoami)
|
||||||
|
原因: [填写具体原因]
|
||||||
|
回滚前版本: abc1234 (feat: update hero animation)
|
||||||
|
回滚后版本: $(git log --oneline -1)
|
||||||
|
验证结果: ✅ 所有关键页面正常
|
||||||
|
耗时: 约 15 分钟
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 6.2 发送通知(钉钉/企业微信/邮件)
|
||||||
|
# 示例:发送邮件给 dev-team@novalon.cn
|
||||||
|
cat << EOF | mail -s "⚠️ 已回滚 Novalon Website" dev-team@novalon.cn
|
||||||
|
Novalon Website 已完成紧急回滚
|
||||||
|
|
||||||
|
回滚时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
回滚原因: [填写具体原因,如"首页白屏"]
|
||||||
|
回滚前版本: abc1234 (feat: update hero animation)
|
||||||
|
回滚后版本: $(git describe --tags || git log --oneline -1)
|
||||||
|
验证状态: ✅ 所有关键页面已恢复正常访问
|
||||||
|
|
||||||
|
请相关开发者尽快排查问题根因。
|
||||||
|
|
||||||
|
回滚详情:
|
||||||
|
- 操作人: $(whoami)
|
||||||
|
- 服务器: 139.155.109.62
|
||||||
|
- 域名: https://novalon.cn
|
||||||
|
- 日志位置: /tmp/rollback-log.txt
|
||||||
|
|
||||||
|
技术支持联系人:
|
||||||
|
- 值班工程师: [填写姓名] - [填写电话]
|
||||||
|
- 技术负责人: [填写姓名] - [填写电话]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ 回滚通知已发送"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 **Type 2:Docker 容器级回滚(当 Type 1 不可用时)**
|
||||||
|
|
||||||
|
### **适用场景**
|
||||||
|
- 本地代码库损坏或不可用
|
||||||
|
- 需要立即恢复,来不及重新构建
|
||||||
|
- 服务器上有自动备份
|
||||||
|
|
||||||
|
#### **步骤(全部在服务器上执行)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 到生产服务器
|
||||||
|
ssh root@139.155.109.62
|
||||||
|
|
||||||
|
# Step 1: 查看当前容器状态
|
||||||
|
docker ps | grep novalon
|
||||||
|
|
||||||
|
# 预期输出:
|
||||||
|
# CONTAINER ID IMAGE STATUS NAMES
|
||||||
|
# xxxxxxxxxx novalon-website:latest Up 2 hours novalon-nginx-secure
|
||||||
|
|
||||||
|
# Step 2: 查看可用的备份
|
||||||
|
ls -lth /home/novalon/docker-app/novalon-static_backup_* | head -5
|
||||||
|
|
||||||
|
# 预期输出:
|
||||||
|
# drwxr-xr-x 20260512_153000 ← 最新备份(1 小时前)
|
||||||
|
# drwxr-xr-x 20260512_140000 ← 之前的备份(2 小时前)
|
||||||
|
# drwxr-xr-x 20260511_180000 ← 昨天的备份
|
||||||
|
|
||||||
|
# Step 3: 选择最近的备份进行恢复
|
||||||
|
BACKUP_DIR=$(ls -dt /home/novalon/docker-app/novalon-static_backup_* | head -1)
|
||||||
|
CURRENT_DIR="/home/novalon/docker-app/novalon-static"
|
||||||
|
|
||||||
|
echo "将回滚到备份: $BACKUP_DIR"
|
||||||
|
|
||||||
|
# Step 4: 执行回滚
|
||||||
|
rm -rf "$CURRENT_DIR"
|
||||||
|
cp -r "$BACKUP_DIR" "$CURRENT_DIR"
|
||||||
|
|
||||||
|
# Step 5: 重载 Nginx 使更改生效
|
||||||
|
docker exec novalon-nginx-secure nginx -s reload
|
||||||
|
|
||||||
|
# Step 6: 验证
|
||||||
|
curl -I https://novalon.cn/ | head -1
|
||||||
|
# 预期: HTTP/2 200
|
||||||
|
|
||||||
|
echo "✅ Docker 容器级回滚完成"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 **Type 3:CDN 缓存清理(配合 Type 1 或 Type 2 使用)**
|
||||||
|
|
||||||
|
### **何时需要?**
|
||||||
|
- 回滚后用户仍看到旧版本的缓存内容
|
||||||
|
- 更新了 CSS/JS 文件但浏览器未加载最新版本
|
||||||
|
|
||||||
|
#### **方法 A:强制刷新 Nginx 缓存(推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上执行
|
||||||
|
ssh root@139.155.109.62
|
||||||
|
|
||||||
|
# 临时禁用缓存(5 分钟后记得改回来)
|
||||||
|
cat > /tmp/disable-cache.conf << 'EOF'
|
||||||
|
location / {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||||
|
expires 0;
|
||||||
|
# ... 其他配置保持不变
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 应用配置并重载 Nginx
|
||||||
|
docker exec novalon-nginx-secure nginx -s reload
|
||||||
|
|
||||||
|
# 等待 5 分钟让所有用户的缓存过期
|
||||||
|
sleep 300
|
||||||
|
|
||||||
|
# 恢复正常缓存策略(重要!否则影响性能)
|
||||||
|
# 将原来的 nginx.conf 放回去
|
||||||
|
docker exec novalon-nginx-secure nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **方法 B:版本化静态资源(长期方案)**
|
||||||
|
|
||||||
|
如果你的项目使用了 `next.config.ts` 中的 `assetPrefix`,Next.js 会自动为静态资源添加 hash:
|
||||||
|
|
||||||
|
```
|
||||||
|
旧版本: /_next/static/css/abc123.css
|
||||||
|
新版本: /_next/static/css/def456.css
|
||||||
|
```
|
||||||
|
|
||||||
|
这样浏览器会自动请求新的 URL,无需手动清除缓存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 **紧急联系人列表**
|
||||||
|
|
||||||
|
| 角色 | 姓名 | 电话/微信 | 职责 | 备注 |
|
||||||
|
|------|------|-----------|------|------|
|
||||||
|
| **值班工程师** | 待填写 | 待填写 | 执行回滚操作 | 第一响应人 |
|
||||||
|
| **技术负责人** | 待填写 | 待填写 | 决策是否回滚 | P0/P1 故障需其批准 |
|
||||||
|
| **产品负责人** | 待填写 | 待填写 | 评估业务影响 | P1/P2 故障需通知 |
|
||||||
|
| **运维支持** | 待填写 | 待填写 | 服务器/DNS 层面支持 | 当涉及基础设施问题时 |
|
||||||
|
| **Sentry 告警接收人** | dev-team@novalon.cn | - | 错误监控告警 | Sentry 配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **回滚后复盘模板(必填!)**
|
||||||
|
|
||||||
|
每次回滚完成后,**必须在 24 小时内**填写此模板并发送到团队群:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Novalon Website 回滚复盘报告
|
||||||
|
|
||||||
|
**基本信息**
|
||||||
|
- 回滚日期和时间:
|
||||||
|
- 发现问题时间:
|
||||||
|
- 开始回滚时间:
|
||||||
|
- 回滚完成时间:
|
||||||
|
- 总宕机时间: ___ 分钟
|
||||||
|
|
||||||
|
**问题分类**
|
||||||
|
- [ ] 代码 Bug(哪个 commit 引入的?commit hash: _____)
|
||||||
|
- [ ] 依赖兼容性问题(哪个包升级导致?包名及版本: _____)
|
||||||
|
- [ ] 配置错误(nginx/docker/env?具体是哪个配置?)
|
||||||
|
- [ ] 第三方服务故障(CDN/DNS/SSL 证书?服务商是谁?)
|
||||||
|
- [ ] 其他: _____
|
||||||
|
|
||||||
|
**问题根因分析**
|
||||||
|
(详细描述为什么会出现这个问题)
|
||||||
|
|
||||||
|
**影响范围评估**
|
||||||
|
- 影响用户数估算: 约 ___ 人
|
||||||
|
- 受影响的页面/功能:
|
||||||
|
- 是否造成数据丢失: 是/否(如果是,哪些数据?)
|
||||||
|
- 业务损失估算:
|
||||||
|
|
||||||
|
**回滚操作详情**
|
||||||
|
- 回滚类型: Type 1 (代码回滚) / Type 2 (Docker 容器级) / Type 3 (CDN 清理)
|
||||||
|
- 回滚前版本:
|
||||||
|
- 回滚后版本:
|
||||||
|
- 是否成功恢复: 是/否
|
||||||
|
- 是否需要后续修复: 是/否
|
||||||
|
|
||||||
|
**改进措施(防止再次发生)**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
**如何改进测试/CI 流程以提前发现问题**
|
||||||
|
- [ ] 补充单元测试覆盖(具体是哪个模块?)
|
||||||
|
- [ ] 增加 E2E 测试用例(具体场景是什么?)
|
||||||
|
- [ ] 在 CI Pipeline 中增加质量门禁(什么条件?)
|
||||||
|
- [ ] 接入 Sentry 错误监控(已完成 ✓)
|
||||||
|
- [ ] 完善 Code Review 流程(什么标准?)
|
||||||
|
- [ ] 其他:
|
||||||
|
|
||||||
|
**经验教训总结**
|
||||||
|
(用一两句话总结这次回滚学到了什么)
|
||||||
|
|
||||||
|
**附件**
|
||||||
|
- 截图/日志: (如有)
|
||||||
|
- 相关 Issue/PR 链接: (如有)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **常见问题排查(FAQ)**
|
||||||
|
|
||||||
|
### **Q1:回滚后网站仍然显示旧内容?**
|
||||||
|
|
||||||
|
**A:CDN 缓存未刷新。解决方案:**
|
||||||
|
1. 执行 Type 3: CDN 缓存清理
|
||||||
|
2. 或等待 CDN TTL 过期(通常 1-2 小时)
|
||||||
|
3. 或使用 Ctrl+Shift+R 强制刷新浏览器缓存
|
||||||
|
|
||||||
|
### **Q2:Git checkout 失败,提示"有未提交的更改"?**
|
||||||
|
|
||||||
|
**A:先暂存或丢弃这些更改:**
|
||||||
|
```bash
|
||||||
|
# 查看有哪些未提交的更改
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 方案 A:暂时保存(推荐)
|
||||||
|
git stash push -m "temp-save"
|
||||||
|
|
||||||
|
# 方案 B:直接丢弃(谨慎!不可逆)
|
||||||
|
git reset --hard HEAD
|
||||||
|
|
||||||
|
# 然后再执行 git checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Q3:npm run build:clean 构建失败?**
|
||||||
|
|
||||||
|
**A:可能的原因及解决方法:**
|
||||||
|
1. **依赖未安装完全**:运行 `rm -rf node_modules && npm install`
|
||||||
|
2. **TypeScript 类型错误**:运行 `npx tsc --noEmit` 查看详细错误
|
||||||
|
3. **ESLint 错误**:运行 `npm run lint` 查看详情
|
||||||
|
4. **内存不足**:增加 Node.js 内存限制 `NODE_OPTIONS=--max-old-space-size=4096 npm run build:clean`
|
||||||
|
|
||||||
|
### **Q4:SSH 连接到服务器失败?**
|
||||||
|
|
||||||
|
**A:检查以下几点:**
|
||||||
|
1. **网络连接**:`ping 139.155.109.62`
|
||||||
|
2. **SSH 服务**:`ssh -v root@139.155.109.62`(查看详细日志)
|
||||||
|
3. **防火墙**:确认端口 22 未被封锁
|
||||||
|
4. **SSH Key**:确认私钥文件权限正确 (`chmod 600 ~/.ssh/id_rsa`)
|
||||||
|
|
||||||
|
### **Q5:回滚后发现数据库或其他动态内容丢失?**
|
||||||
|
|
||||||
|
**A:对于纯静态网站(你的情况),不存在这个问题!**
|
||||||
|
- Novalon Website 是纯静态导出,不依赖数据库
|
||||||
|
- 所有内容都在 `dist/` 目录中
|
||||||
|
- 回滚只是替换静态文件,不会影响任何持久化数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ **快速参考卡片(打印出来贴在工位上)**
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════╗
|
||||||
|
║ Novalon Website 紧急回滚速查表 ║
|
||||||
|
╠══════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ P0 触发条件: ║
|
||||||
|
║ • 首页白屏 / 5xx 错误 ║
|
||||||
|
║ • 安全漏洞 ║
|
||||||
|
║ • 域名/SSL 问题 ║
|
||||||
|
║ ║
|
||||||
|
║ 快速回滚命令(3 步完成): ║
|
||||||
|
║ ║
|
||||||
|
║ 1. git log --oneline -10 ║
|
||||||
|
║ # 找到稳定版本的 commit ║
|
||||||
|
║ ║
|
||||||
|
║ 2. git checkout <stable-commit-hash> ║
|
||||||
|
║ # 切换到稳定版本 ║
|
||||||
|
║ ║
|
||||||
|
║ 3. ./deploy-dist.sh ║
|
||||||
|
║ # 重新构建并部署 ║
|
||||||
|
║ ║
|
||||||
|
║ 验证命令: ║
|
||||||
|
║ curl -I https://novalon.cn/ ║
|
||||||
|
║ # 预期: HTTP/2 200 ║
|
||||||
|
║ ║
|
||||||
|
║ 紧急联系人: ║
|
||||||
|
║ • 值班工程师: [姓名] - [电话] ║
|
||||||
|
║ • 技术负责人: [姓名] - [电话] ║
|
||||||
|
║ • 运维支持: [姓名] - [电话] ║
|
||||||
|
║ ║
|
||||||
|
║ 服务器信息: ║
|
||||||
|
║ IP: 139.155.109.62 ║
|
||||||
|
║ 用户: root ║
|
||||||
|
║ 域名: novalon.cn ║
|
||||||
|
║ 容器: novalon-nginx-secure ║
|
||||||
|
║ ║
|
||||||
|
║ 目标恢复时间: < 10 分钟 ║
|
||||||
|
╚══════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ **回滚完成清单**
|
||||||
|
|
||||||
|
请在回滚完成后逐项确认:
|
||||||
|
|
||||||
|
**立即确认(回滚后 5 分钟内)**
|
||||||
|
- [ ] 网站首页可访问,无白屏
|
||||||
|
- [ ] HTTP 状态码返回 200
|
||||||
|
- [ ] 关键页面(About, Contact, Products)可访问
|
||||||
|
- [ ] 导航栏正常工作
|
||||||
|
- [ ] SEO 元数据完整(title, description, OG tags)
|
||||||
|
- [ ] 已通知相关人员(邮件/群消息)
|
||||||
|
|
||||||
|
**后续确认(回滚后 24 小时内)**
|
||||||
|
- [ ] 填写回滚复盘报告
|
||||||
|
- [ ] 分析问题根因并记录
|
||||||
|
- [ ] 制定预防措施
|
||||||
|
- [ ] 更新 CI/CD Pipeline(如有必要)
|
||||||
|
- [ ] 团队内部分享经验教训
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 **相关文档链接**
|
||||||
|
|
||||||
|
- **部署脚本**: [deploy-dist.sh](./deploy-dist.sh)
|
||||||
|
- **Jenkins Pipeline**: [Jenkinsfile](./Jenkinsfile)
|
||||||
|
- **Sentry 监控指南**: [docs/sentry-setup-guide.md](./docs/sentry-setup-guide.md)
|
||||||
|
- **技术栈降级指南**: [scripts/downgrade-stack.sh](./scripts/downgrade-stack.sh)
|
||||||
|
- **CONTEXT.md**: [CONTEXT.md](./CONTEXT.md)(领域术语和关键决策)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**💡 最后提醒**:
|
||||||
|
- 保持冷静,按照步骤执行
|
||||||
|
- 每一步都要验证再继续
|
||||||
|
- 记录所有操作,方便事后复盘
|
||||||
|
- 回滚不是失败,是负责任的表现!
|
||||||
|
|
||||||
|
**祝你好运!🍀**
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# GA4 错误监控集成完成报告
|
||||||
|
|
||||||
|
## 📊 集成概述
|
||||||
|
|
||||||
|
**日期**: 2026-01-15
|
||||||
|
**技术方案**: Google Analytics 4 (GA4) 异常事件追踪
|
||||||
|
**成本**: 完全免费(使用现有 GA4 账户)
|
||||||
|
**状态**: ✅ 已完成并验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 实现目标
|
||||||
|
|
||||||
|
1. **零成本错误监控** - 利用现有 GA4 账户,无需额外付费服务
|
||||||
|
2. **全自动错误捕获** - 无需手动埋点,自动捕获所有 JavaScript 错误
|
||||||
|
3. **生产环境可用** - 已通过 TypeScript 检查和构建测试
|
||||||
|
4. **智能过滤** - 自动忽略无关错误(ResizeObserver、Script error 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 新增/修改的文件
|
||||||
|
|
||||||
|
### 核心文件
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/lib/analytics.ts` | **新建** | Analytics 核心模块,包含所有追踪函数 |
|
||||||
|
| `src/components/analytics/GlobalErrorTracker.tsx` | **新建** | 全局错误追踪器组件 |
|
||||||
|
| `src/app/layout.tsx` | **修改** | 集成 GlobalErrorTracker |
|
||||||
|
| `src/app/test-error-tracking/page.tsx` | **新建** | 错误监控测试页面 |
|
||||||
|
|
||||||
|
### 辅助文件
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/components/analytics/CookieConsent.tsx` | **修改** | 修复 CookiePreferences 类型定义 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 技术实现细节
|
||||||
|
|
||||||
|
### 1. 错误捕获机制
|
||||||
|
|
||||||
|
```
|
||||||
|
GlobalErrorTracker 组件(客户端)
|
||||||
|
↓
|
||||||
|
useEffect 初始化时添加事件监听器:
|
||||||
|
├── window.addEventListener('error', handleGlobalError)
|
||||||
|
│ └── 捕获 JavaScript 运行时错误
|
||||||
|
│ └── trackError('javascript_error', message, false, {filename, lineno, colno, stack})
|
||||||
|
│
|
||||||
|
├── window.addEventListener('unhandledrejection', handleUnhandledRejection)
|
||||||
|
│ └── 捕获 Promise 未捕获异常
|
||||||
|
│ └── trackError('unhandled_promise_rejection', message, false, {reason_type, stack})
|
||||||
|
│
|
||||||
|
└── document.addEventListener('error', handleResourceError, true)
|
||||||
|
└── 捕获资源加载失败(图片、脚本、样式等)
|
||||||
|
└── trackError('resource_loading_error', 'Failed to load resource', false)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GA4 事件数据结构
|
||||||
|
|
||||||
|
每个错误都会作为 `exception` 事件发送到 GA4,包含以下参数:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
event_name: 'exception', // 固定事件名
|
||||||
|
description: '[error_type] msg', // 错误描述(包含类型前缀)
|
||||||
|
fatal: 'true' | 'false', // 是否致命错误
|
||||||
|
url: '当前页面URL', // 发生错误的页面
|
||||||
|
timestamp: 'ISO时间戳', // 发生时间
|
||||||
|
// ... 其他上下文信息(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ErrorBoundary 集成
|
||||||
|
|
||||||
|
React 组件渲染错误会自动被 [ErrorBoundary](src/components/ui/error-boundary.tsx) 捕获并上报:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
|
trackError('react_error', error.message, true); // 标记为致命错误
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试与验证
|
||||||
|
|
||||||
|
### 快速测试步骤
|
||||||
|
|
||||||
|
1. **启动开发服务器**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **访问测试页面**
|
||||||
|
```
|
||||||
|
http://localhost:3000/test-error-tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **点击测试按钮**
|
||||||
|
- 📛 测试 JavaScript 错误
|
||||||
|
- ⚠️ 测试 Promise 异常
|
||||||
|
- 💥 测试 React 致命错误
|
||||||
|
- 🌐 测试网络错误
|
||||||
|
|
||||||
|
4. **查看控制台日志**(开发模式)
|
||||||
|
```
|
||||||
|
[GA4] Error tracked: {
|
||||||
|
description: "[javascript_error] 测试错误...",
|
||||||
|
fatal: "false",
|
||||||
|
url: "http://localhost:3000/test-error-tracking",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **在 GA4 后台验证**
|
||||||
|
- 登录 [Google Analytics](https://analytics.google.com/)
|
||||||
|
- 报告 → 实时 → 事件计数
|
||||||
|
- 搜索 `exception`
|
||||||
|
- 应该能在 30 秒内看到错误事件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 在生产环境使用
|
||||||
|
|
||||||
|
### 1. 确保环境变量已配置
|
||||||
|
|
||||||
|
检查 `.env.local` 或 `.env.production` 文件:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-LGTLCR15KM
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 构建并部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:clean
|
||||||
|
# 然后执行部署脚本
|
||||||
|
./deploy-dist.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 监控错误报告
|
||||||
|
|
||||||
|
#### 方法 A:实时监控(推荐用于上线初期)
|
||||||
|
- GA4 后台 → 报告 → 实时
|
||||||
|
- 查看实时错误流
|
||||||
|
|
||||||
|
#### 方法 B:定期审查(日常运维)
|
||||||
|
- GA4 后台 → 报告 → 参与度 → 事件
|
||||||
|
- 筛选 `exception` 事件
|
||||||
|
- 导出数据进行分析
|
||||||
|
|
||||||
|
#### 方法 C:设置自动告警(高级)
|
||||||
|
- 使用 Google Data Studio 创建仪表盘
|
||||||
|
- 设置异常检测规则
|
||||||
|
- 配置邮件通知
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 自定义配置
|
||||||
|
|
||||||
|
### 忽略特定错误
|
||||||
|
|
||||||
|
编辑 [GlobalErrorTracker.tsx](src/components/analytics/GlobalErrorTracker.tsx):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const IGNORED_ERRORS = [
|
||||||
|
/_AutofillCallbackHandler/,
|
||||||
|
/ResizeObserver loop limit exceeded/,
|
||||||
|
// 添加你想要忽略的错误模式...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加自定义错误上下文
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
trackError('custom_error', 'Something went wrong', false, {
|
||||||
|
user_id: '12345',
|
||||||
|
feature_name: 'checkout',
|
||||||
|
session_id: 'abc-xyz',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改致命错误阈值
|
||||||
|
|
||||||
|
默认情况下,React 渲染错误会被标记为 `fatal: true`。你可以根据业务需求调整:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ErrorBoundary 中
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
const isFatal = error.message.includes('critical');
|
||||||
|
trackError('react_error', error.message, isFatal);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项与限制
|
||||||
|
|
||||||
|
### GA4 错误监控的限制
|
||||||
|
|
||||||
|
1. **数据延迟**:GA4 数据通常有 24-48 小时的处理延迟(实时报告除外)
|
||||||
|
2. **采样率**:高流量网站可能会有数据采样
|
||||||
|
3. **数据保留**:GA4 免费版数据保留 2 个月(付费版 14 个月)
|
||||||
|
4. **事件限制**:每个 session 最多 500 个事件
|
||||||
|
5. **参数限制**:每个事件最多 25 个参数
|
||||||
|
|
||||||
|
### 与专业错误监控工具对比
|
||||||
|
|
||||||
|
| 特性 | GA4 错误监控 | Sentry (免费版) | Fundebug (免费版) |
|
||||||
|
|------|-------------|----------------|------------------|
|
||||||
|
| **成本** | ✅ 免费 | ✅ 免费(5K 次/月) | ✅ 免费(3K 次/月) |
|
||||||
|
| **国内访问** | ✅ 可访问(需 VPN) | ❌ 受限 | ✅ 可访问 |
|
||||||
|
| **错误聚合** | ⚠️ 基础 | ✅ 高级 | ✅ 高级 |
|
||||||
|
| **Source Map** | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
|
||||||
|
| **性能影响** | ✅ 低 | ⚠️ 中等 | ⚠️ 中等 |
|
||||||
|
| **告警通知** | ⚠️ 有限 | ✅ 完善 | ✅ 完善 |
|
||||||
|
| **团队协作** | ⚠️ 基础 | ✅ 完善 | ✅ 完善 |
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
|
||||||
|
- **适合场景**:个人项目、小型网站、预算有限的项目
|
||||||
|
- **升级时机**:当需要更专业的错误分析、Source Map 支持、团队协作功能时,考虑迁移到 Sentry 或 Fundebug
|
||||||
|
- **混合方案**:同时使用 GA4 + Sentry/Fundebug,GA4 用于业务分析,专业工具用于错误调试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 后续优化建议
|
||||||
|
|
||||||
|
### 短期优化(1-2 周)
|
||||||
|
|
||||||
|
1. **设置 GA4 告警**
|
||||||
|
- 配置自定义异常检测
|
||||||
|
- 设置每日错误摘要邮件
|
||||||
|
|
||||||
|
2. **创建错误仪表盘**
|
||||||
|
- 使用 Google Data Studio
|
||||||
|
- 可视化错误趋势、类型分布
|
||||||
|
|
||||||
|
3. **添加用户标识**
|
||||||
|
- 在错误上下文中包含用户 ID(如果适用)
|
||||||
|
- 便于复现问题
|
||||||
|
|
||||||
|
### 中期优化(1-3 个月)
|
||||||
|
|
||||||
|
1. **错误分类体系**
|
||||||
|
- 定义错误严重级别(P0/P1/P2/P3)
|
||||||
|
- 建立响应 SLA
|
||||||
|
|
||||||
|
2. **与 CI/CD 集成**
|
||||||
|
- 将错误率纳入质量门禁
|
||||||
|
- 自动化回归测试
|
||||||
|
|
||||||
|
3. **性能关联分析**
|
||||||
|
- 关联 Core Web Vitals 与错误率
|
||||||
|
- 发现性能瓶颈
|
||||||
|
|
||||||
|
### 长期优化(3-6 个月)
|
||||||
|
|
||||||
|
1. **A/B 测试影响评估**
|
||||||
|
- 分析新功能上线后的错误变化
|
||||||
|
- 量化代码质量改进效果
|
||||||
|
|
||||||
|
2. **预测性错误预防**
|
||||||
|
- 基于历史数据预测潜在问题
|
||||||
|
- 主动修复高风险代码路径
|
||||||
|
|
||||||
|
3. **考虑迁移到专业工具**
|
||||||
|
- 如果错误量增长或需求复杂化
|
||||||
|
- 评估 Sentry/Fundebug 的 ROI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 支持与故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
**Q: 为什么我在 GA4 后台看不到错误?**
|
||||||
|
|
||||||
|
A: 请检查以下几点:
|
||||||
|
1. 确保 `NEXT_PUBLIC_GA_MEASUREMENT_ID` 环境变量已正确设置
|
||||||
|
2. 使用"实时"报告而非普通报告(有 24-48 小时延迟)
|
||||||
|
3. 检查浏览器控制台是否有 `[GA4] Error tracked:` 日志
|
||||||
|
4. 确认没有广告拦截器阻止 gtag.js 加载
|
||||||
|
|
||||||
|
**Q: 如何区分开发和生产环境的错误?**
|
||||||
|
|
||||||
|
A: 可以在错误上下文中添加环境标识:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
trackError('test_error', 'Test message', false, {
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Q: 错误数据量太大怎么办?**
|
||||||
|
|
||||||
|
A: GA4 免费版每个属性每月最多 1000 万次事件。如果超出:
|
||||||
|
1. 调整 IGNORED_ERRORS 过滤更多无关错误
|
||||||
|
2. 降低采样率(只上报部分错误)
|
||||||
|
3. 升级到 GA4 360 或迁移到专业错误监控工具
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 成功指标
|
||||||
|
|
||||||
|
### 上线后应关注的核心指标
|
||||||
|
|
||||||
|
1. **错误率**:< 1% 的 session 包含至少一个错误
|
||||||
|
2. **致命错误率**:< 0.1% 的 session 包含致命错误
|
||||||
|
3. **平均修复时间 (MTTR)**:< 24 小时(P0 错误 < 4 小时)
|
||||||
|
4. **错误趋势**:周环比下降或持平
|
||||||
|
|
||||||
|
### 基准线建立建议
|
||||||
|
|
||||||
|
上线第一周:
|
||||||
|
- 收集基准数据
|
||||||
|
- 识别主要错误类型
|
||||||
|
- 建立监控仪表盘
|
||||||
|
|
||||||
|
第二周起:
|
||||||
|
- 设定改善目标
|
||||||
|
- 定期审查错误报告
|
||||||
|
- 持续优化代码质量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
✅ **已完成**:GA4 错误监控完全集成
|
||||||
|
✅ **已验证**:TypeScript 检查通过,构建成功
|
||||||
|
✅ **可测试**:提供专用测试页面 `/test-error-tracking`
|
||||||
|
✅ **可扩展**:支持自定义错误过滤和上下文
|
||||||
|
✅ **零成本**:利用现有 GA4 账户,无需额外费用
|
||||||
|
|
||||||
|
**下一步行动**:
|
||||||
|
1. 访问测试页面验证功能
|
||||||
|
2. 部署到生产环境
|
||||||
|
3. 在 GA4 后台设置监控
|
||||||
|
4. 建立定期审查流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: v1.0
|
||||||
|
**最后更新**: 2026-01-15
|
||||||
|
**维护者**: 张翔 (Zhang Xiang)
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# Jenkins CI/CD 配置指南
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
本文档指导你如何在 Jenkins 中配置自动化 CI/CD 流水线,实现:
|
||||||
|
- ✅ Gitea Webhook 自动触发构建
|
||||||
|
- ✅ 多阶段质量门禁(Lint + TypeCheck + Test + Build)
|
||||||
|
- ✅ 自动部署到生产服务器
|
||||||
|
- ✅ 构建通知(邮件/钉钉/企业微信)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 前置条件
|
||||||
|
|
||||||
|
### 1. Jenkins 插件安装
|
||||||
|
|
||||||
|
确保以下插件已安装在 Jenkins 中:
|
||||||
|
|
||||||
|
| 插件名称 | 用途 | 必需 |
|
||||||
|
|---------|------|------|
|
||||||
|
| **Pipeline** | 支持 Jenkinsfile 流水线 | ✅ 是 |
|
||||||
|
| **Git Plugin** | Git 仓库集成 | ✅ 是 |
|
||||||
|
| **Generic Webhook Trigger** | Gitea Webhook 触发 | ✅ 是 |
|
||||||
|
| **NodeJS Plugin** | Node.js 环境管理 | ✅ 是 |
|
||||||
|
| **HTML Publisher** | 发布测试报告 | ✅ 是 |
|
||||||
|
| **SSH Agent Plugin** | SSH 部署连接 | ✅ 是 |
|
||||||
|
| **Email Extension** | 邮件通知 | ⚠️ 可选 |
|
||||||
|
| **DingTalk Plugin** | 钉钉通知 | ⚠️ 可选 |
|
||||||
|
|
||||||
|
#### 安装步骤:
|
||||||
|
|
||||||
|
1. 登录 Jenkins:`http://<your-jenkins-ip>:8080`
|
||||||
|
2. 导航到:**Manage Jenkins → Plugins → Available plugins**
|
||||||
|
3. 搜索并安装上述插件
|
||||||
|
4. 重启 Jenkins
|
||||||
|
|
||||||
|
### 2. 全局工具配置
|
||||||
|
|
||||||
|
#### 配置 Node.js:
|
||||||
|
|
||||||
|
1. **Manage Jenkins → Tools**
|
||||||
|
2. 找到 **NodeJS** 部分
|
||||||
|
3. 点击 **Add NodeJS**
|
||||||
|
4. 配置如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: Node.js 18
|
||||||
|
Install automatically: ✅ 勾选
|
||||||
|
Version: 18.20.x (LTS)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 点击 **Save**
|
||||||
|
|
||||||
|
#### 配置 Git:
|
||||||
|
|
||||||
|
1. 同样在 **Tools** 页面
|
||||||
|
2. 找到 **Git** 部分
|
||||||
|
3. 确保 Git 路径正确(通常 `/usr/bin/git`)
|
||||||
|
4. 如果未安装,选择 **Install automatically**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 创建 Pipeline 任务
|
||||||
|
|
||||||
|
### 方法 A:通过 Jenkins UI 创建(推荐新手)
|
||||||
|
|
||||||
|
#### 步骤 1:创建新任务
|
||||||
|
|
||||||
|
1. 点击 **New Item**
|
||||||
|
2. 输入任务名称:`novalon-website-ci-cd`
|
||||||
|
3. 选择 **Pipeline**
|
||||||
|
4. 点击 **OK**
|
||||||
|
|
||||||
|
#### 步骤 2:配置 General
|
||||||
|
|
||||||
|
```
|
||||||
|
☑️ This project is parameterized (勾选)
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
┌─ Boolean Parameter
|
||||||
|
│ Name: DEPLOY_TO_PRODUCTION
|
||||||
|
│ Default Value: false
|
||||||
|
│ Description: 是否部署到生产环境(仅在 main 分支且测试通过后可用)
|
||||||
|
└──────────────────────────────
|
||||||
|
|
||||||
|
☑️ GitHub project (可选)
|
||||||
|
Project URL: https://git.f.novalon.cn/novalon/novalon-website
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 3:配置 Build Triggers(触发器)
|
||||||
|
|
||||||
|
##### 选项 1:Gitea Webhook 触发(推荐)
|
||||||
|
|
||||||
|
1. 勾选 **Trigger builds remotely**
|
||||||
|
2. 设置 Authentication Token:`novalon-website-ci-token`
|
||||||
|
|
||||||
|
3. 在 **Build Triggers** 部分找到 **Generic Webhook Trigger** 或 **Gitea Webhook**:
|
||||||
|
- 如果使用 **Generic Webhook Trigger** 插件:
|
||||||
|
```
|
||||||
|
Token: novalon-website-ci-token
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **保存任务后,获取 Webhook URL**:
|
||||||
|
```
|
||||||
|
http://<jenkins-ip>:8080/generic-webhook-trigger/invoke?token=novalon-website-ci-token
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 选项 2:轮询 SCM(备选)
|
||||||
|
|
||||||
|
```
|
||||||
|
☑️ Poll SCM
|
||||||
|
Schedule: H/15 * * * * (每 15 分钟检查一次)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 4:配置 Pipeline(流水线定义)
|
||||||
|
|
||||||
|
选择 **Pipeline script from SCM**:
|
||||||
|
|
||||||
|
```
|
||||||
|
SCM: Git
|
||||||
|
Repositories:
|
||||||
|
Repository URL: https://git.f.novalon.cn/novalon/novalon-website.git
|
||||||
|
Credentials: (添加 Gitea 凭据)
|
||||||
|
Branch: */dev (或 */main)
|
||||||
|
|
||||||
|
Script Path: Jenkinsfile
|
||||||
|
Lightweight checkout: ☑️ 勾选
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 5:添加凭据(Credentials)
|
||||||
|
|
||||||
|
1. 点击 **Add → Jenkins**
|
||||||
|
2. 选择 **Username with password**
|
||||||
|
3. 输入 Gitea 账号密码:
|
||||||
|
- Username: `你的 Gitea 用户名`
|
||||||
|
- Password: `你的 Gitea 密码或 Personal Access Token`
|
||||||
|
4. ID: `gitea-credentials`(自定义)
|
||||||
|
5. 点击 **Add**
|
||||||
|
|
||||||
|
#### 步骤 6:保存并测试
|
||||||
|
|
||||||
|
1. 点击 **Save** 保存配置
|
||||||
|
2. 点击 **Build Now** 手动触发一次构建
|
||||||
|
3. 查看控制台输出确认流程正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方法 B:Pipeline as Code(Jenkinsfile 已包含)
|
||||||
|
|
||||||
|
由于项目根目录已包含 [Jenkinsfile](../Jenkinsfile),Jenkins 会自动读取。
|
||||||
|
|
||||||
|
只需确保:
|
||||||
|
1. ✅ Jenkinsfile 在仓库根目录
|
||||||
|
2. ✅ Pipeline 任务指向正确的分支和 Jenkinsfile 路径
|
||||||
|
3. ✅ 所需插件已安装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 配置 Gitea Webhook
|
||||||
|
|
||||||
|
### 步骤 1:在 Gitea 中创建 Webhook
|
||||||
|
|
||||||
|
1. 打开 Gitea 仓库:`https://git.f.novalon.cn/novalon/novalon-website`
|
||||||
|
2. 点击 **Settings → Webhooks → Add Webhook → Gitea**
|
||||||
|
3. 配置如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
Target URL:
|
||||||
|
http://<your-jenkins-ip>:8080/generic-webhook-trigger/invoke?token=novalon-website-ci-token
|
||||||
|
|
||||||
|
HTTP Method: POST
|
||||||
|
Content Type: application/json
|
||||||
|
Secret: (留空或设置密钥)
|
||||||
|
|
||||||
|
Trigger On:
|
||||||
|
☑️ Push Events
|
||||||
|
☐ Create Events
|
||||||
|
☐ Pull Request Events (可选)
|
||||||
|
☐ Tag Events (可选)
|
||||||
|
|
||||||
|
Branch Filter:
|
||||||
|
dev, main, develop (逗号分隔,留空表示所有分支)
|
||||||
|
|
||||||
|
Active: ☑️ 勾选
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 点击 **Add Webhook**
|
||||||
|
|
||||||
|
### 步骤 2:测试 Webhook
|
||||||
|
|
||||||
|
1. 在 Webhook 列表中,点击刚创建的 Webhook
|
||||||
|
2. 点击 **Test Delivery → Push Event**
|
||||||
|
3. 查看 **Recent Deliveries** 确认状态为 **200 OK**
|
||||||
|
4. 检查 Jenkins 是否收到触发并开始构建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 配置部署凭据(SSH)
|
||||||
|
|
||||||
|
### 步骤 1:生成 SSH 密钥对(如果还没有)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 Jenkins 服务器上执行
|
||||||
|
ssh-keygen -t rsa -b 4096 -C "jenkins@novalon" -f ~/.ssh/jenkins_deploy_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:将公钥添加到生产服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制公钥内容
|
||||||
|
cat ~/.ssh/jenkins_deploy_key.pub
|
||||||
|
|
||||||
|
# 在生产服务器(139.155.109.62)上执行
|
||||||
|
echo "公钥内容" >> ~/.ssh/authorized_keys
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:在 Jenkins 中添加 SSH 凭据
|
||||||
|
|
||||||
|
1. **Manage Jenkins → Credentials → System → Global credentials**
|
||||||
|
2. **Add Credentials → SSH Username with private key**
|
||||||
|
3. 配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: deploy-server-ssh
|
||||||
|
Username: root
|
||||||
|
Private Key:
|
||||||
|
☑️ Enter directly
|
||||||
|
(粘贴私钥内容:~/.ssh/jenkins_deploy_key 的内容)
|
||||||
|
Passphrase: (如果有则填写,否则留空)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 点击 **Save**
|
||||||
|
|
||||||
|
### 步骤 4:更新 Jenkinsfile 使用凭据
|
||||||
|
|
||||||
|
编辑 [Jenkinsfile](../Jenkinsfile) 中的部署阶段,添加:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
stage('🚀 部署到生产环境') {
|
||||||
|
when {
|
||||||
|
allOf {
|
||||||
|
anyOf { branch 'main'; branch 'develop' }
|
||||||
|
expression { return params.DEPLOY_TO_PRODUCTION }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sshagent(['deploy-server-ssh']) {
|
||||||
|
sh '''
|
||||||
|
# 部署命令...
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📧 配置通知
|
||||||
|
|
||||||
|
### 选项 1:邮件通知
|
||||||
|
|
||||||
|
1. **Manage Jenkins → System**
|
||||||
|
2. 找到 **E-mail Notification** 部分:
|
||||||
|
```
|
||||||
|
SMTP server: smtp.qq.com (或你的 SMTP 服务器)
|
||||||
|
SMTP Port: 465
|
||||||
|
Use SSL: ☑️
|
||||||
|
Username: your-email@qq.com
|
||||||
|
Password: 你的授权码(不是 QQ 密码)
|
||||||
|
Reply-To Address: your-email@qq.com
|
||||||
|
Charset: UTF-8
|
||||||
|
```
|
||||||
|
3. 点击 **Test configuration** 发送测试邮件
|
||||||
|
4. 点击 **Save**
|
||||||
|
|
||||||
|
### 选项 2:钉钉机器人通知(推荐国内团队)
|
||||||
|
|
||||||
|
1. 创建钉钉群机器人:
|
||||||
|
- 打开钉钉群 → 设置 → 智能群助手 → 添加机器人
|
||||||
|
- 选择 **Custom(通过 Webhook 地址接入)**
|
||||||
|
- 复制 Webhook URL:`https://oapi.dingtalk.com/robot/send?access_token=xxx`
|
||||||
|
|
||||||
|
2. 安装 **DingTalk Plugin**(或在 Jenkinsfile 中使用 curl):
|
||||||
|
|
||||||
|
在 Jenkinsfile 的 `post { always {} }` 部分添加:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
script {
|
||||||
|
def webhookUrl = 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN'
|
||||||
|
def message = """
|
||||||
|
{
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {
|
||||||
|
"title": "Jenkins 构建通知",
|
||||||
|
"text": "## Jenkins 构建通知\\n> 项目:${env.PROJECT_NAME}\\n> 分支:${env.GIT_BRANCH}\\n> 构建号:#${env.BUILD_NUMBER}\\n> 状态:${currentBuild.result ?: 'SUCCESS'}\\n> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss')}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
sh "curl -s -X POST '${webhookUrl}' -H 'Content-Type: application/json' -d '${message}'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 手动触发构建验证
|
||||||
|
|
||||||
|
### 第一次构建建议手动触发:
|
||||||
|
|
||||||
|
1. 进入任务页面:`novalon-website-ci-cd`
|
||||||
|
2. 点击 **Build Now**
|
||||||
|
3. 点击正在运行的构建号查看实时日志
|
||||||
|
4. 确认以下阶段成功:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Stage 1: 🔧 环境准备
|
||||||
|
✅ Stage 2: 📥 安装依赖
|
||||||
|
✅ Stage 3: 🔍 ESLint 检查
|
||||||
|
✅ Stage 4: 🔍 TypeScript 类型检查
|
||||||
|
✅ Stage 5: 🧪 单元测试 + 覆盖率
|
||||||
|
✅ Stage 6: 🏗️ 生产构建
|
||||||
|
⏭️ Stage 7: 🧪 E2E 测试(需要 Playwright 浏览器)
|
||||||
|
⏭️ Stage 8: ⚡ 性能测试(需要 k6)
|
||||||
|
⏭️ Stage 9: 🚀 部署(需要勾选 DEPLOY_TO_PRODUCTION 参数)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看构建产物:
|
||||||
|
|
||||||
|
1. 点击完成的构建号
|
||||||
|
2. 左侧菜单查看:
|
||||||
|
- **Console Output** - 完整日志
|
||||||
|
- **Coverage Report (Jest)** - 测试覆盖率报告
|
||||||
|
- **Edit Build Information** - 构建元数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 常见问题排查
|
||||||
|
|
||||||
|
### 问题 1:Webhook 未触发构建
|
||||||
|
|
||||||
|
**症状**:推送代码后 Jenkins 无反应
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查 Gitea Webhook 的 **Recent Deliveries** 状态码是否为 200
|
||||||
|
2. 确认 Jenkins URL 正确且可从 Gitea 访问
|
||||||
|
3. 检查 Jenkins 安全设置(Configure Global Security):
|
||||||
|
- CSRF Protection → Enable proxy compatibility
|
||||||
|
- Allow anonymous read access(如果需要)
|
||||||
|
|
||||||
|
### 问题 2:Node.js 版本错误
|
||||||
|
|
||||||
|
**症状**:`ERROR: Node.js version not found`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 确认已在 **Manage Jenkins → Tools** 中配置 Node.js 18
|
||||||
|
2. 检查 Jenkinsfile 中的 `nodejs(nodeJSInstallationName: '18')` 名称匹配
|
||||||
|
|
||||||
|
### 问题 3:npm install 失败
|
||||||
|
|
||||||
|
**症状**:`npm ERR! network error`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查 npm registry 配置(默认使用 npmmirror 镜像)
|
||||||
|
2. 如果是私有包,配置 `.npmrc` 文件
|
||||||
|
3. 检查 Jenkins 服务器的网络连接
|
||||||
|
|
||||||
|
### 问题 4:SSH 部署失败
|
||||||
|
|
||||||
|
**症状**:`Permission denied (publickey)`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 确认 SSH 私钥已正确添加到 Jenkins 凭据
|
||||||
|
2. 确认生产服务器的 `~/.ssh/authorized_keys` 包含对应公钥
|
||||||
|
3. 测试 SSH 连接:`ssh root@139.155.109.62`
|
||||||
|
|
||||||
|
### 问题 5:ESLint 或 TypeScript 检查失败
|
||||||
|
|
||||||
|
**症状**:构建在代码质量检查阶段失败
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 查看完整错误日志
|
||||||
|
2. 在本地运行修复:
|
||||||
|
```bash
|
||||||
|
npm run lint --fix
|
||||||
|
npm run type-check
|
||||||
|
```
|
||||||
|
3. 修复问题后重新提交代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 监控与优化
|
||||||
|
|
||||||
|
### 构建历史分析
|
||||||
|
|
||||||
|
1. 进入任务主页
|
||||||
|
2. 查看构建趋势图(成功率、平均耗时)
|
||||||
|
3. 识别瓶颈阶段(通常是安装依赖或测试)
|
||||||
|
|
||||||
|
### 性能优化建议
|
||||||
|
|
||||||
|
1. **启用构建缓存**:
|
||||||
|
```groovy
|
||||||
|
environment {
|
||||||
|
CI_CACHE_PATH = "${HOME}/.cache/novalon-website"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **并行化测试**:在 Jenkinsfile 中使用 `parallel` 执行多个测试套件
|
||||||
|
|
||||||
|
3. **增量构建**:只对变更的模块运行测试(需要 monorepo 结构)
|
||||||
|
|
||||||
|
4. **Docker 化构建环境**:使用 Docker agent 保证一致性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 完成清单
|
||||||
|
|
||||||
|
完成以下所有项后,CI/CD 流水线即可正常运行:
|
||||||
|
|
||||||
|
- [x] 推送代码到 Gitea ✅
|
||||||
|
- [ ] 安装 Jenkins 必要插件
|
||||||
|
- [ ] 配置全局工具(Node.js 18, Git)
|
||||||
|
- [ ] 创建 Pipeline 任务(指向 dev 分支的 Jenkinsfile)
|
||||||
|
- [ ] 添加 Gitea 凭据(Username with Password)
|
||||||
|
- [ ] 配置 Gitea Webhook(Push Events → Jenkins Generic Trigger)
|
||||||
|
- [ ] 添加部署 SSH 凭据(生产服务器访问权限)
|
||||||
|
- [ ] 配置通知渠道(邮件/钉钉)
|
||||||
|
- [ ] 手动触发首次构建验证
|
||||||
|
- [ ] 确认所有阶段成功通过
|
||||||
|
- [ ] 测试自动触发(推送代码 → Jenkins 自动构建)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步操作
|
||||||
|
|
||||||
|
### 立即执行:
|
||||||
|
|
||||||
|
1. **登录 Jenkins 并按照上述步骤创建任务**
|
||||||
|
2. **配置 Gitea Webhook**
|
||||||
|
3. **手动触发第一次构建**
|
||||||
|
4. **查看构建日志确认流程正常**
|
||||||
|
|
||||||
|
### 本周内完成:
|
||||||
|
|
||||||
|
1. **设置构建失败告警**
|
||||||
|
2. **建立定期审查机制(每周审查构建成功率)**
|
||||||
|
3. **优化构建性能(目标:<10 分钟完成全流程)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
如遇到问题,请检查:
|
||||||
|
|
||||||
|
1. **Jenkins 系统日志**:`Manage Jenkins → System Log`
|
||||||
|
2. **Gitea Webhook 日志**:Webhook 页面的 Recent Deliveries
|
||||||
|
3. **构建控制台输出**:每次构建的 Console Output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: v1.0
|
||||||
|
**最后更新**: 2026-01-15
|
||||||
|
**适用版本**: Jenkins 2.400+, Gitea 1.19+
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
# Sentry 错误监控快速接入指南(免费版)
|
||||||
|
|
||||||
|
> **适用场景**: Novalon Website 项目
|
||||||
|
> **Sentry 版本**: 免费版 (5,000 errors/month)
|
||||||
|
> **预计耗时**: 15-30 分钟
|
||||||
|
|
||||||
|
## 📋 前置条件
|
||||||
|
|
||||||
|
- ✅ Sentry 账号 (如果没有,见下方注册步骤)
|
||||||
|
- ✅ 项目已安装 `@sentry/nextjs` 依赖
|
||||||
|
- ✅ 已创建 `sentry.*.config.ts` 配置文件
|
||||||
|
|
||||||
|
## 🚀 步骤 1:注册 Sentry 免费账号(如果还没有)
|
||||||
|
|
||||||
|
### 1.1 访问 Sentry 官网
|
||||||
|
|
||||||
|
打开浏览器访问: **https://sentry.io/signup**
|
||||||
|
|
||||||
|
### 1.2 注册账号
|
||||||
|
|
||||||
|
**推荐方式(最快)**:
|
||||||
|
- 点击 "Sign up with GitHub" 或 "Sign up with Google"
|
||||||
|
- 使用你的 Gitea/GitHub 账号直接登录
|
||||||
|
|
||||||
|
**或者使用邮箱注册**:
|
||||||
|
1. 输入工作邮箱(建议用 `dev-team@novalon.cn`)
|
||||||
|
2. 设置密码
|
||||||
|
3. 验证邮箱(检查收件箱,点击验证链接)
|
||||||
|
|
||||||
|
### 1.3 创建组织(Organization)
|
||||||
|
|
||||||
|
注册成功后:
|
||||||
|
1. 进入 Dashboard
|
||||||
|
2. 点击 "Create Organization"
|
||||||
|
3. 组织名称填写: `Novalon` 或 `Novalon-Tech`
|
||||||
|
4. 选择团队类型: **Developer Team**(免费)
|
||||||
|
5. 点击 "Create Organization"
|
||||||
|
|
||||||
|
### 1.4 创建项目(Project)
|
||||||
|
|
||||||
|
在组织内创建新项目:
|
||||||
|
|
||||||
|
1. 点击 "Create Project"
|
||||||
|
2. 搜索并选择框架: **Next.js**
|
||||||
|
3. 项目名称填写: `novalon-website`
|
||||||
|
4. 团队选择: 默认团队即可
|
||||||
|
5. 点击 "Create Project"
|
||||||
|
|
||||||
|
### 1.5 获取 DSN(Data Source Name)
|
||||||
|
|
||||||
|
项目创建完成后:
|
||||||
|
|
||||||
|
1. 进入项目 → **Settings** → **Client Keys (DSN)**
|
||||||
|
2. 复制 **DSN** 值,格式如下:
|
||||||
|
```
|
||||||
|
https://examplePublicKey@o0.ingest.sentry.io/123456
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **保存这个 DSN 值**,下一步会用到!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 步骤 2:配置环境变量
|
||||||
|
|
||||||
|
### 2.1 编辑 `.env.local` 文件
|
||||||
|
|
||||||
|
在项目根目录下创建或编辑 `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制示例配置
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Google Analytics (如果有真实值,请替换)
|
||||||
|
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||||
|
|
||||||
|
# Sentry DSN (替换为你在步骤 1.5 中获取的真实 DSN)
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=https://your-real-dsn@o0.ingest.sentry.io/your-project-id
|
||||||
|
SENTRY_DSN=https://your-real-dsn@o0.ingest.sentry.io/your-project-id
|
||||||
|
|
||||||
|
# CDN 配置(可选)
|
||||||
|
CDN_DOMAIN=
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ 重要提醒:**
|
||||||
|
- ❌ 不要将 `.env.local` 提交到 Git!它已经在 `.gitignore` 中
|
||||||
|
- ✅ 只在本地开发环境和生产服务器上配置此文件
|
||||||
|
- 🔒 DSN 是公开的,不包含敏感信息(可以安全地用在客户端)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 步骤 3:安装 Sentry SDK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 @sentry/nextjs 及其依赖
|
||||||
|
npm install @sentry/nextjs @sentry/tracing
|
||||||
|
|
||||||
|
# 或者使用 pnpm(如果你用 pnpm)
|
||||||
|
pnpm add @sentry/nextjs @sentry/tracing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 步骤 4:修改 next.config.ts 以集成 Sentry
|
||||||
|
|
||||||
|
当前你的 [next.config.ts](./next.config.ts) 需要添加 Sentry wrapper:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const cdnDomain = process.env.CDN_DOMAIN || '';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
distDir: 'dist',
|
||||||
|
output: 'export',
|
||||||
|
assetPrefix: cdnDomain || undefined,
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
compress: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react'],
|
||||||
|
},
|
||||||
|
compiler: {
|
||||||
|
removeConsole: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withSentryConfig(
|
||||||
|
nextConfig,
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
disableLogger: true,
|
||||||
|
hideSourceMaps: true,
|
||||||
|
// 自动上传 source maps 到 Sentry(推荐生产环境开启)
|
||||||
|
disableClientWebpackPlugin: process.env.NODE_ENV !== 'production',
|
||||||
|
disableServerWebpackPlugin: process.env.NODE_ENV !== 'production',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:我已经为你创建了修改后的版本,可以直接替换原文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 步骤 5:验证 Sentry 是否正常工作
|
||||||
|
|
||||||
|
### 5.1 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 触发测试错误
|
||||||
|
|
||||||
|
浏览器访问以下地址之一:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方法 A:访问 Sentry 内置的错误页面
|
||||||
|
http://localhost:3000/sentry-example-error
|
||||||
|
|
||||||
|
# 方法 B:在浏览器控制台手动触发(更灵活)
|
||||||
|
# 打开 http://localhost:3000,然后在控制台执行:
|
||||||
|
throw new TestError('Sentry 测试错误 - 如果你能看到这条错误,说明 Sentry 工作正常!');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 在 Sentry Dashboard 验证
|
||||||
|
|
||||||
|
1. 登录 https://sentry.io
|
||||||
|
2. 进入你的组织 → `novalon-website` 项目
|
||||||
|
3. 点击左侧菜单 **Issues**
|
||||||
|
4. 应该能看到刚刚触发的测试错误!
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
```
|
||||||
|
✅ Issues 列表中显示:
|
||||||
|
- Error Type: Error (或 TestError)
|
||||||
|
- Message: "Sentry 测试错误..."
|
||||||
|
- First Seen: 几秒钟前
|
||||||
|
- Events: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 步骤 6:配置告警规则(可选但强烈推荐)
|
||||||
|
|
||||||
|
### 6.1 创建错误数量告警
|
||||||
|
|
||||||
|
1. 在 Sentry Dashboard 中,点击 **Alerts** → **Create Alert**
|
||||||
|
2. 选择 alert 类型: **"When an issue is created"** 或 **"Number of errors is high"**
|
||||||
|
3. 配置阈值:
|
||||||
|
```
|
||||||
|
Alert Type: Number of Errors
|
||||||
|
Threshold: > 10 errors in 5 minutes
|
||||||
|
Environment: production
|
||||||
|
```
|
||||||
|
4. 配置通知方式:
|
||||||
|
- **Email**: 发送到 dev-team@novalon.cn
|
||||||
|
- **Slack/DingTalk**: 如果集成了的话
|
||||||
|
5. 保存告警规则
|
||||||
|
|
||||||
|
### 6.2 创建性能退化告警(进阶)
|
||||||
|
|
||||||
|
如果你想监控 Core Web Vitals:
|
||||||
|
|
||||||
|
1. 进入 **Settings** → **Performance**
|
||||||
|
2. 开启 **Web Vitals** 监控
|
||||||
|
3. 设置阈值:
|
||||||
|
- LCP (Largest Contentful Paint): > 2.5 秒
|
||||||
|
- FID (First Input Delay): > 100 ms
|
||||||
|
- CLS (Cumulative Layout Shift): > 0.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 步骤 7:在生产环境中启用 Sentry
|
||||||
|
|
||||||
|
当你准备部署到生产环境时:
|
||||||
|
|
||||||
|
### 7.1 在生产服务器上配置环境变量
|
||||||
|
|
||||||
|
SSH 到你的生产服务器 (139.155.109.62):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@139.155.109.62
|
||||||
|
|
||||||
|
# 编辑 Docker 环境变量或 .env 文件
|
||||||
|
nano /home/novalon/docker-app/.env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
添加以下内容:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_GA_MEASUREMENT_ID=你的真实GA_ID
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=你的真实SENTRY_DSN
|
||||||
|
SENTRY_DSN=你的真实SENTRY_DSN
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 重新构建和部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 回到本地开发机
|
||||||
|
./deploy-dist.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Jenkins Pipeline 会自动:
|
||||||
|
1. 运行测试
|
||||||
|
2. 构建生产版本
|
||||||
|
3. 部署到服务器
|
||||||
|
4. Sentry 开始收集生产环境的错误数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 步骤 8:查看和分析错误报告
|
||||||
|
|
||||||
|
### 8.1 Issues 页面(问题列表)
|
||||||
|
|
||||||
|
进入 **Issues** 标签页,你可以看到:
|
||||||
|
|
||||||
|
| 列 | 说明 | 示例 |
|
||||||
|
|---|------|------|
|
||||||
|
| **Issue Title** | 错误标题 | TypeError: Cannot read property 'x' of undefined |
|
||||||
|
| **First Seen** | 首次发生时间 | 2 hours ago |
|
||||||
|
| **Events** | 发生次数 | 23 users, 45 events |
|
||||||
|
| **Users Affected** | 影响用户数 | 23 users |
|
||||||
|
| **Level** | 严重程度 | error / warning / info |
|
||||||
|
|
||||||
|
### 8.2 Performance 页面(性能监控)
|
||||||
|
|
||||||
|
如果你的应用开启了性能监控:
|
||||||
|
|
||||||
|
- **Transaction**(事务):每个页面加载的详细耗时
|
||||||
|
- **Span**(时间跨度):具体操作的时间分解
|
||||||
|
- **Web Vitals**:LCP、FID、CLS 的趋势图
|
||||||
|
|
||||||
|
### 8.3 Releases 页面(版本追踪)
|
||||||
|
|
||||||
|
每次部署后,Sentry 会自动关联到 Git commit:
|
||||||
|
|
||||||
|
```
|
||||||
|
Release: v1.0.0-stable
|
||||||
|
Commit: abc1234 (feat: downgrade to stable stack)
|
||||||
|
Deploy Time: 2026-05-12 14:30:00
|
||||||
|
Errors: 0 (or X if any)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 高级用法(按需启用)
|
||||||
|
|
||||||
|
### #1:标记用户身份(提升错误排查效率)
|
||||||
|
|
||||||
|
在你的业务代码中(如用户登录后):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
// 当用户登录后
|
||||||
|
Sentry.setUser({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
segment: 'premium', // or 'free'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:当某个用户报错时,你可以直接看到是哪个用户遇到的问题。
|
||||||
|
|
||||||
|
### #2:附加自定义上下文(业务相关信息)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在关键操作中添加上下文
|
||||||
|
Sentry.setContext('order', {
|
||||||
|
orderId: 'ORD-20260512-001',
|
||||||
|
amount: 999,
|
||||||
|
product: 'Enterprise License',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果这里出错,Sentry 会自动附带这些信息
|
||||||
|
```
|
||||||
|
|
||||||
|
### #3:手动捕获特定错误
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/contact');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Contact form submission failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 手动发送到 Sentry,并附加额外信息
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
tags: { feature: 'contact-form' },
|
||||||
|
extra: { formData: { name, email, subject } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### #4:设置面包屑导航(Breadcrumbs)—— 记录用户操作路径
|
||||||
|
|
||||||
|
Sentry 会自动记录:
|
||||||
|
- UI 交互(click、input、keypress)
|
||||||
|
- 导航变化(URL 变化)
|
||||||
|
- Console 日志
|
||||||
|
- HTTP 请求(XHR/Fetch)
|
||||||
|
- DOM 变化
|
||||||
|
|
||||||
|
你还可以手动添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Sentry.addBreadcrumb({
|
||||||
|
category: 'ui',
|
||||||
|
message: 'User clicked "Submit" on contact form',
|
||||||
|
level: 'info',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 故障排除
|
||||||
|
|
||||||
|
### 问题 1:Sentry Dashboard 看不到任何错误
|
||||||
|
|
||||||
|
**可能原因及解决方案**:
|
||||||
|
|
||||||
|
| 原因 | 解决方案 |
|
||||||
|
|------|---------|
|
||||||
|
| DSN 未正确配置 | 检查 `.env.local` 中的 `NEXT_PUBLIC_SENTRY_DSN` 是否有值 |
|
||||||
|
| 环境变量未生效 | 重启开发服务器 (`npm run dev`) |
|
||||||
|
| `enabled: false` 在开发环境 | 检查 `sentry.client.config.ts` 中的 `enabled` 条件 |
|
||||||
|
| 网络问题 | 检查是否能访问 `o0.ingest.sentry.io` |
|
||||||
|
|
||||||
|
**调试方法**:
|
||||||
|
```typescript
|
||||||
|
// 在 sentry.client.config.ts 中临时添加
|
||||||
|
console.log('[Sentry] DSN:', SENTRY_DSN);
|
||||||
|
console.log('[Sentry] Enabled:', process.env.NODE_ENV !== 'development');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2:Source Maps 未上传
|
||||||
|
|
||||||
|
**现象**:Sentry 显示的错误堆栈是压缩后的代码,难以阅读。
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
1. 安装 `@sentry/webpack-plugin`:
|
||||||
|
```bash
|
||||||
|
npm install --save-dev @sentry/webpack-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 在 `next.config.ts` 中配置:
|
||||||
|
```typescript
|
||||||
|
export default withSentryConfig(
|
||||||
|
nextConfig,
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
hideSourceMaps: false, // 改为 false
|
||||||
|
// 其他配置...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: './dist/**',
|
||||||
|
ignore: ['node_modules'],
|
||||||
|
urlPrefix: '~/_next',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 3:免费额度超限
|
||||||
|
|
||||||
|
**Sentry 免费版限制**:
|
||||||
|
- 每月 5,000 个错误事件
|
||||||
|
- 保留 30 天的数据
|
||||||
|
- 5 个团队成员
|
||||||
|
|
||||||
|
**如果超限**:
|
||||||
|
1. 升级到 Developer 版本 ($26/月,50,000 errors/month)
|
||||||
|
2. 或者优化错误过滤(减少噪音)
|
||||||
|
3. 或者在 `ignoreErrors` 中添加更多模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档链接
|
||||||
|
|
||||||
|
- **Sentry 官方文档**: https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
- **Next.js 集成指南**: https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
- **最佳实践**: https://docs.sentry.io/product/best-practices/event-grouping/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 接入完成清单
|
||||||
|
|
||||||
|
请逐项确认:
|
||||||
|
|
||||||
|
- [ ] 已注册 Sentry 账号并创建项目
|
||||||
|
- [ ] 已获取 DSN 并配置到 `.env.local`
|
||||||
|
- [ ] 已安装 `@sentry/nextjs` 和 `@sentry/tracing`
|
||||||
|
- [ ] 已修改 `next.config.ts` 添加 `withSentryConfig`
|
||||||
|
- [ ] 已运行 `npm run dev` 并访问 `/sentry-example-error`
|
||||||
|
- [ ] 已在 Sentry Dashboard 看到测试错误
|
||||||
|
- [ ] (可选)已配置告警规则
|
||||||
|
- [ ] (可选)已部署到生产环境并验证
|
||||||
|
|
||||||
|
全部完成后,你的 Novalon Website 就拥有了**企业级的错误监控能力**!🎉
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const cdnDomain = process.env.CDN_DOMAIN || '';
|
const cdnDomain = process.env.CDN_DOMAIN || '';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig = {
|
||||||
distDir: 'dist',
|
distDir: 'dist',
|
||||||
output: 'export',
|
output: 'export',
|
||||||
assetPrefix: cdnDomain || undefined,
|
assetPrefix: cdnDomain || undefined,
|
||||||
Generated
+5878
-5058
File diff suppressed because it is too large
Load Diff
+12
-14
@@ -38,22 +38,19 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antv/g2": "^5.4.8",
|
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@sentry/nextjs": "^10.52.0",
|
||||||
"@types/three": "^0.183.1",
|
"@sentry/tracing": "^7.120.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"critters": "^0.0.23",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "^14.2.21",
|
||||||
"react": "19.2.3",
|
"react": "^18.3.1",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.183.1",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -67,19 +64,19 @@
|
|||||||
"@commitlint/config-conventional": "^20.5.0",
|
"@commitlint/config-conventional": "^20.5.0",
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^18",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||||
"@typescript-eslint/parser": "^8.57.0",
|
"@typescript-eslint/parser": "^8.57.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
"chrome-launcher": "^1.2.1",
|
"chrome-launcher": "^1.2.1",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "^0.2.4",
|
"eslint-config-next": "^14.2.21",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
@@ -88,7 +85,8 @@
|
|||||||
"k6": "^0.0.0",
|
"k6": "^0.0.0",
|
||||||
"lighthouse": "^13.0.3",
|
"lighthouse": "^13.0.3",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^16.4.0",
|
||||||
"tailwindcss": "^4",
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "🔄 Novalon Website 技术栈降级脚本"
|
||||||
|
echo "从: Next.js 16 + React 19 + Tailwind CSS 4"
|
||||||
|
echo "到: Next.js 14.2 + React 18.3 + Tailwind CSS 3.4"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
BACKUP_DIR="./backup-$(date +%Y%m%d_%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "📦 Step 1: 备份关键配置文件..."
|
||||||
|
cp package.json "$BACKUP_DIR/"
|
||||||
|
cp postcss.config.mjs "$BACKUP_DIR/" 2>/dev/null || true
|
||||||
|
cp src/app/globals.css "$BACKUP_DIR/" 2>/dev/null || true
|
||||||
|
echo "✅ 配置文件已备份到 $BACKUP_DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🗑️ Step 2: 清理旧依赖和缓存..."
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -f package-lock.json pnpm-lock.yaml yarn.lock
|
||||||
|
rm -rf .next dist
|
||||||
|
rm -rf .cache
|
||||||
|
echo "✅ 缓存已清理"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📥 Step 3: 安装新版本依赖..."
|
||||||
|
npm install --registry=https://registry.npmmirror.com || {
|
||||||
|
echo "❌ npm install 失败!尝试使用淘宝镜像..."
|
||||||
|
npm install --registry=https://registry.npmmirror.com
|
||||||
|
}
|
||||||
|
echo "✅ 依赖安装完成"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Step 4: 验证核心依赖版本..."
|
||||||
|
echo ""
|
||||||
|
echo "┌─────────────────┬──────────────────┬────────────────────┐"
|
||||||
|
echo "│ 依赖包 │ 期望版本 │ 实际安装版本 │"
|
||||||
|
echo "├─────────────────┼──────────────────┼────────────────────┤"
|
||||||
|
|
||||||
|
check_version() {
|
||||||
|
local pkg=$1
|
||||||
|
local expected=$2
|
||||||
|
local actual=$(node -e "console.log(require('./node_modules/$pkg/package.json').version)" 2>/dev/null || echo "未安装")
|
||||||
|
|
||||||
|
if [[ "$actual" == *"$expected"* ]]; then
|
||||||
|
status="✅"
|
||||||
|
else
|
||||||
|
status="⚠️"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "│ %-15s │ %-16s │ %-18s │\n" "$pkg" "$expected" "$actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_version "next" "^14.2"
|
||||||
|
check_version "react" "^18.3"
|
||||||
|
check_version "react-dom" "^18.3"
|
||||||
|
check_version "tailwindcss" "^3.4"
|
||||||
|
check_version "@types/react" "^18"
|
||||||
|
check_version "@types/react-dom" "^18"
|
||||||
|
check_version "eslint-config-next" "^14"
|
||||||
|
|
||||||
|
echo "└─────────────────┴──────────────────┴────────────────────┘"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "🧪 Step 5: 运行快速验证..."
|
||||||
|
if npm run lint --silent > /dev/null 2>&1; then
|
||||||
|
echo "✅ ESLint 检查通过"
|
||||||
|
else
|
||||||
|
echo "⚠️ ESLint 检查有警告(可忽略)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if npx tsc --noEmit --pretty > /dev/null 2>&1; then
|
||||||
|
echo "✅ TypeScript 类型检查通过"
|
||||||
|
else
|
||||||
|
echo "⚠️ TypeScript 类型检查有错误(需要修复)"
|
||||||
|
echo " 请运行 'npx tsc --noEmit' 查看详细错误"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🏗️ Step 6: 测试构建(可选,耗时较长)..."
|
||||||
|
read -p "是否现在运行生产构建测试?(y/n): " build_choice
|
||||||
|
|
||||||
|
if [ "$build_choice" = "y" ] || [ "$build_choice" = "Y" ]; then
|
||||||
|
echo " 正在构建...(预计 2-5 分钟)"
|
||||||
|
if npm run build:clean > /tmp/build-output.log 2>&1; then
|
||||||
|
echo "✅ 生产构建成功!"
|
||||||
|
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
file_count=$(find dist -type f | wc -l)
|
||||||
|
dist_size=$(du -sh dist | cut -f1)
|
||||||
|
echo " 📁 生成了 $file_count 个文件,总大小: $dist_size"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ 构建失败!查看日志:"
|
||||||
|
echo " cat /tmp/build-output.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⏭️ 跳过构建测试(稍后可手动运行 'npm run build:clean')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "🎉 技术栈降级完成!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "📋 后续操作:"
|
||||||
|
echo " 1. 运行 'npm run dev' 启动开发服务器验证"
|
||||||
|
echo " 2. 运行 'npm run test:unit' 执行单元测试"
|
||||||
|
echo " 3. 运行 'npm run test:e2e' 执行 E2E 测试"
|
||||||
|
echo " 4. 如果一切正常,提交代码到 Git"
|
||||||
|
echo ""
|
||||||
|
echo "📋 回滚方法(如果遇到问题):"
|
||||||
|
echo " cp $BACKUP_DIR/package.json ./package.json"
|
||||||
|
echo " # ... 恢复其他配置文件"
|
||||||
|
echo " rm -rf node_modules && npm install"
|
||||||
|
echo ""
|
||||||
|
echo "💡 备份位置: $BACKUP_DIR/"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN || '';
|
||||||
|
|
||||||
|
if (SENTRY_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
enabled: process.env.NODE_ENV !== 'development',
|
||||||
|
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 0,
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
|
ignoreErrors: [
|
||||||
|
/_AutofillCallbackHandler/,
|
||||||
|
/NetworkError/,
|
||||||
|
/webkit\.messageHandlers/,
|
||||||
|
/Non-Error promise rejection captured/,
|
||||||
|
/^Loading CSS chunk.*failed/,
|
||||||
|
/^Loading chunk.*failed/,
|
||||||
|
/^Failed to fetch dynamically imported module/,
|
||||||
|
],
|
||||||
|
|
||||||
|
denyUrls: [
|
||||||
|
/extensions\//i,
|
||||||
|
/^chrome:\/\//i,
|
||||||
|
/webappstoolbars/i,
|
||||||
|
/safari-extension:/i,
|
||||||
|
/mypersonalassistant/i,
|
||||||
|
],
|
||||||
|
|
||||||
|
initialScope: {
|
||||||
|
tags: {
|
||||||
|
project: 'novalon-website',
|
||||||
|
version: process.env.npm_package_version || 'unknown',
|
||||||
|
deployment: process.env.NODE_ENV || 'unknown',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: 'anonymous',
|
||||||
|
segment: 'visitor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeSend(event) {
|
||||||
|
if (event.request?.url) {
|
||||||
|
event.request.url = event.request.url.replace(/\/[a-f0-9]{32}/gi, '/[hash]');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.breadcrumbs) {
|
||||||
|
event.breadcrumbs = event.breadcrumbs.filter((breadcrumb) => {
|
||||||
|
return !(
|
||||||
|
breadcrumb.category === 'console' &&
|
||||||
|
breadcrumb.level === 'log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
Sentry.browserTracingIntegration(),
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Sentry] Client-side monitoring initialized');
|
||||||
|
} else {
|
||||||
|
console.warn('[Sentry] NEXT_PUBLIC_SENTRY_DSN not configured, error monitoring disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sentry;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
||||||
|
|
||||||
|
if (SENTRY_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
enabled: process.env.NODE_ENV !== 'development',
|
||||||
|
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sentry;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
||||||
|
|
||||||
|
if (SENTRY_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
enabled: process.env.NODE_ENV !== 'development',
|
||||||
|
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
|
||||||
|
beforeSend(event, hint) {
|
||||||
|
const error = hint.originalException;
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
event.contexts = {
|
||||||
|
...event.contexts,
|
||||||
|
runtime: {
|
||||||
|
name: 'node',
|
||||||
|
version: process.version,
|
||||||
|
},
|
||||||
|
application: {
|
||||||
|
name: 'novalon-website',
|
||||||
|
version: process.env.npm_package_version || 'unknown',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.request?.cookies) {
|
||||||
|
delete event.request.cookies['session_id'];
|
||||||
|
delete event.request.cookies['token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Sentry] Server-side monitoring initialized');
|
||||||
|
} else {
|
||||||
|
console.warn('[Sentry] SENTRY_DSN not configured, server-side error monitoring disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sentry;
|
||||||
@@ -1,31 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { COMPANY_INFO, STATS } from '@/lib/constants';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { FlipClock } from '@/components/ui/flip-clock';
|
import { COMPANY_INFO } from '@/lib/constants';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
import { Users, Target, Award, MapPin, Mail } from 'lucide-react';
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
import { differenceInYears, differenceInMonths, differenceInDays, subYears, subMonths } from 'date-fns';
|
import { Users, Target, Award } from 'lucide-react';
|
||||||
|
|
||||||
export function AboutClient() {
|
export function AboutClient() {
|
||||||
const [operationTime, setOperationTime] = useState({ days: 0, months: 0, years: 0 });
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
useEffect(() => {
|
|
||||||
const foundingDate = new Date('2026-01-15');
|
|
||||||
const calculateTime = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const years = differenceInYears(now, foundingDate);
|
|
||||||
const afterYears = subYears(now, years);
|
|
||||||
const months = differenceInMonths(afterYears, foundingDate);
|
|
||||||
const afterMonths = subMonths(afterYears, months);
|
|
||||||
const days = differenceInDays(afterMonths, foundingDate);
|
|
||||||
setOperationTime({ days, months, years });
|
|
||||||
};
|
|
||||||
calculateTime();
|
|
||||||
const timer = setInterval(calculateTime, 60000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const values = useMemo(() => [
|
const values = useMemo(() => [
|
||||||
{ icon: Target, title: '务实', description: '不追逐风口,只做真正为客户创造价值的事。' },
|
{ icon: Target, title: '务实', description: '不追逐风口,只做真正为客户创造价值的事。' },
|
||||||
@@ -34,193 +19,174 @@ export function AboutClient() {
|
|||||||
], []);
|
], []);
|
||||||
|
|
||||||
const milestones = useMemo(() => [
|
const milestones = useMemo(() => [
|
||||||
{ date: '2026年1月', title: '公司成立', description: '四川睿新致远科技有限公司在成都龙泉驿区正式成立' },
|
{ date: '2026.01', title: '公司成立', description: '四川睿新致远科技有限公司在成都龙泉驿区正式成立' },
|
||||||
{ date: '2026年1月', title: '团队组建', description: '核心团队到位,成员来自多个大型传统IT企业,具备扎实的工程能力和规范化交付经验' },
|
{ date: '2026.01', title: '团队组建', description: '核心团队到位,成员来自多个大型传统IT企业,具备扎实的工程能力和规范化交付经验' },
|
||||||
{ date: '2026年2月', title: '业务启动', description: '推出企业数字化转型咨询与解决方案服务,开始接触首批意向客户' },
|
{ date: '2026.02', title: '业务启动', description: '推出企业数字化转型咨询与解决方案服务,开始接触首批意向客户' },
|
||||||
{ date: '2026年3月', title: '产品研发', description: '自主研发的ERP、CRM等产品启动研发,逐步构建产品矩阵' },
|
{ date: '2026.03', title: '产品研发', description: '自主研发的ERP、CRM等产品启动研发,逐步构建产品矩阵' },
|
||||||
{ date: '2026年5月', title: '研发推进', description: '多款产品进入核心功能开发阶段,同步开展早期用户体验计划' },
|
{ date: '2026.05', title: '研发推进', description: '多款产品进入核心功能开发阶段,同步开展早期用户体验计划' },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
|
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '关于我们', href: '/about' }]} />
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '关于我们' }]} />
|
<PageNav items={[{ label: '关于我们' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">About</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">About</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
关于我们
|
关于我们
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者。
|
以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者。
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="pb-16">
|
<section className="pb-20">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="p-8 rounded-xl border border-[#E5E5E5]"
|
className="bg-[var(--color-bg-section)] rounded-2xl p-8 md:p-12"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6">关于 {COMPANY_INFO.shortName}</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-8">
|
||||||
<p className="text-xl font-semibold text-[#1C1C1C] mb-4">智连未来,成长伙伴</p>
|
关于 {COMPANY_INFO.shortName}
|
||||||
<p className="text-[#595959] mb-6 leading-relaxed">企业需要的,不是一个高高在上的专家,也不是一个做完就跑的卖家,而是一个能坐下来、一起想办法的同行者。</p>
|
</h2>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-3">智连未来</h3>
|
<div>
|
||||||
<p className="text-[#595959] mb-2 leading-relaxed">我们坚持对行业趋势的深度研究,不追逐昙花一现的概念。</p>
|
<h3 className="text-lg font-semibold text-[var(--color-brand-primary)] mb-3">智连未来</h3>
|
||||||
<p className="text-[#595959] mb-2 leading-relaxed">每一次方案,都源于对您业务场景的洞察;</p>
|
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed">我们坚持对行业趋势的深度研究,不追逐昙花一现的概念。</p>
|
||||||
<p className="text-[#595959] leading-relaxed">每一次连接,都为了让技术真正服务于您的未来。</p>
|
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed">每一次方案,都源于对您业务场景的洞察;</p>
|
||||||
</div>
|
<p className="text-[var(--color-text-muted)] leading-relaxed">每一次连接,都为了让技术真正服务于您的未来。</p>
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-3">成长伙伴</h3>
|
|
||||||
<p className="text-[#595959] mb-2 leading-relaxed">我们不把“项目完成”当作终点。</p>
|
|
||||||
<p className="text-[#595959] mb-2 leading-relaxed">您的业务增长了吗?您的团队能力提升了吗?</p>
|
|
||||||
<p className="text-[#595959] mb-2 leading-relaxed">您下一次遇到难题时,还会第一个想到我们吗?</p>
|
|
||||||
<p className="text-[#595959] mt-3 leading-relaxed">这些问题,比“项目是否按时完成”更让我们在意。</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
|
||||||
className="p-8 rounded-xl border border-[#E5E5E5]"
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6">品牌承诺</h2>
|
|
||||||
<p className="text-[#595959] mb-4 leading-relaxed">我们承诺:</p>
|
|
||||||
<ul className="space-y-2 mb-6">
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-[#C41E3A] font-bold">✓</span>
|
|
||||||
<span className="text-[#595959]">不卖您用不上的技术</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-[#C41E3A] font-bold">✓</span>
|
|
||||||
<span className="text-[#595959]">不说不懂业务的术语</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-[#C41E3A] font-bold">✓</span>
|
|
||||||
<span className="text-[#595959]">不做路过就忘的“一锤子买卖”</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-[#1C1C1C] leading-relaxed font-medium">
|
|
||||||
我们只做一件事:成为您数字化转型路上,信得过的成长伙伴。
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<FlipClock
|
|
||||||
years={operationTime.years}
|
|
||||||
months={operationTime.months}
|
|
||||||
days={operationTime.days}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="grid grid-cols-2 md:grid-cols-4 gap-4"
|
|
||||||
>
|
|
||||||
{STATS.map((stat, idx) => (
|
|
||||||
<div key={idx} className="p-6 bg-[#F5F5F5] rounded-lg text-center">
|
|
||||||
<div className="text-3xl font-bold text-[#C41E3A] mb-1">{stat.value}</div>
|
|
||||||
<div className="text-sm text-[#595959]">{stat.label}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--color-brand-primary)] mb-3">成长伙伴</h3>
|
||||||
|
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed">我们不把“项目完成”当作终点。</p>
|
||||||
|
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed">您的业务增长了吗?您的团队能力提升了吗?</p>
|
||||||
|
<p className="text-[var(--color-text-muted)] leading-relaxed">您下一次遇到难题时,还会第一个想到我们吗?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-[var(--color-border-light)]">
|
||||||
|
<p className="text-[var(--color-text-muted)] mb-4 leading-relaxed">我们承诺:</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
'不卖您用不上的技术',
|
||||||
|
'不说不懂业务的术语',
|
||||||
|
'不做路过就忘的"一锤子买卖"',
|
||||||
|
].map((promise) => (
|
||||||
|
<div key={promise} className="flex items-start gap-2">
|
||||||
|
<span className="text-[var(--color-brand-primary)] font-bold mt-0.5">✓</span>
|
||||||
|
<span className="text-[var(--color-text-muted)] text-sm">{promise}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[var(--color-text-primary)] leading-relaxed font-medium mt-4">
|
||||||
|
我们只做一件事:成为您数字化转型路上,信得过的成长伙伴。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-20 bg-[var(--color-bg-section)]">
|
||||||
|
<div className="container-wide">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<motion.h2
|
||||||
|
{...fadeUp}
|
||||||
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="text-2xl font-bold text-[var(--color-text-primary)] mb-10"
|
||||||
|
>
|
||||||
|
核心价值观
|
||||||
|
</motion.h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{values.map((value) => (
|
||||||
|
<motion.div
|
||||||
|
key={value.title}
|
||||||
|
{...fadeUp}
|
||||||
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="flex items-start gap-4 p-6 rounded-xl bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] hover:border-[rgba(var(--color-brand-primary-rgb),0.3)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] flex items-center justify-center shrink-0">
|
||||||
|
<value.icon className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[var(--color-text-primary)] mb-1">{value.title}</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{value.description}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<motion.div
|
<section className="py-20">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container-wide">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<motion.h2
|
||||||
|
{...fadeUp}
|
||||||
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
|
className="text-2xl font-bold text-[var(--color-text-primary)] mb-10"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center">核心价值观</h2>
|
发展历程
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
</motion.h2>
|
||||||
{values.map((value) => (
|
<div className="relative">
|
||||||
<div
|
<div className="absolute left-[4.5rem] top-0 bottom-0 w-px bg-[var(--color-border-primary)] hidden md:block" />
|
||||||
key={value.title}
|
<div className="space-y-8">
|
||||||
className="flex items-start gap-4 p-6 rounded-xl border border-[#E5E5E5] hover:border-[#C41E3A]/30 transition-colors"
|
{milestones.map((milestone, idx) => {
|
||||||
>
|
const isLatest = idx === milestones.length - 1;
|
||||||
<div className="w-10 h-10 rounded-lg bg-[#FEF2F4] flex items-center justify-center shrink-0">
|
return (
|
||||||
<value.icon className="w-5 h-5 text-[#C41E3A]" />
|
<motion.div
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-[#1C1C1C] mb-1">{value.title}</h3>
|
|
||||||
<p className="text-sm text-[#595959]">{value.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center">发展历程</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{milestones.map((milestone) => (
|
|
||||||
<div
|
|
||||||
key={milestone.title}
|
key={milestone.title}
|
||||||
className="flex flex-col md:flex-row md:items-start gap-3 p-5 rounded-lg border border-[#E5E5E5]"
|
{...fadeUp}
|
||||||
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: idx * 0.1 }}
|
||||||
|
className="flex items-start gap-6"
|
||||||
>
|
>
|
||||||
<div className="md:w-28 shrink-0">
|
<div className="md:w-20 shrink-0 text-right hidden md:block">
|
||||||
<span className="text-sm font-medium text-[#C41E3A]">{milestone.date}</span>
|
<span className="text-sm font-mono font-medium text-[var(--color-brand-primary)]">{milestone.date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="relative shrink-0 hidden md:block">
|
||||||
<h3 className="font-semibold text-[#1C1C1C] mb-1 text-sm">{milestone.title}</h3>
|
<div className={`w-3 h-3 rounded-full border-2 border-white shadow-sm mt-1 ${isLatest ? 'bg-[var(--color-brand-primary)] animate-pulse' : 'bg-[var(--color-brand-primary)]'}`} />
|
||||||
<p className="text-sm text-[#595959]">{milestone.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex-1 pb-2">
|
||||||
))}
|
<div className="md:hidden text-sm font-mono font-medium text-[var(--color-brand-primary)] mb-1">{milestone.date}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-[var(--color-text-primary)]">{milestone.title}</h3>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] border border-[var(--color-brand-primary)]/10">
|
||||||
|
进行中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">{milestone.description}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="p-8 rounded-xl border border-[#E5E5E5]"
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center">联系我们</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
|
|
||||||
<MapPin className="w-5 h-5 text-[#C41E3A]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[#595959]">公司地址</p>
|
|
||||||
<p className="text-sm font-medium text-[#1C1C1C]">{COMPANY_INFO.address}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
|
|
||||||
<Mail className="w-5 h-5 text-[#C41E3A]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[#595959]">电子邮箱</p>
|
|
||||||
<p className="text-sm font-medium text-[#1C1C1C]">{COMPANY_INFO.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -72,17 +72,6 @@ jest.mock('@/components/ui/card', () => {
|
|||||||
return { Card, CardContent };
|
return { Card, CardContent };
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('@/components/ui/page-header', () => {
|
|
||||||
const PageHeader = ({ title, description }: { title: string; description?: string }) => (
|
|
||||||
<header>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{description}</p>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
PageHeader.displayName = 'PageHeader';
|
|
||||||
return { PageHeader };
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@/components/ui/flip-clock', () => {
|
jest.mock('@/components/ui/flip-clock', () => {
|
||||||
const FlipClock = ({ years, months, days }: { years: number; months: number; days: number }) => (
|
const FlipClock = ({ years, months, days }: { years: number; months: number; days: number }) => (
|
||||||
<div data-testid="flip-clock">
|
<div data-testid="flip-clock">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
import { useState, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -12,6 +13,7 @@ import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from
|
|||||||
import { COMPANY_INFO } from '@/lib/constants';
|
import { COMPANY_INFO } from '@/lib/constants';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
import { trackContactForm, trackConversion } from '@/lib/analytics';
|
import { trackContactForm, trackConversion } from '@/lib/analytics';
|
||||||
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
|
|
||||||
const contactFormSchema = z.object({
|
const contactFormSchema = z.object({
|
||||||
name: z.string().min(2, '姓名至少需要2个字符'),
|
name: z.string().min(2, '姓名至少需要2个字符'),
|
||||||
@@ -34,6 +36,8 @@ interface FormErrors {
|
|||||||
function ContactFormContent() {
|
function ContactFormContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isSuccessFromRedirect = searchParams.get('success') === 'true';
|
const isSuccessFromRedirect = searchParams.get('success') === 'true';
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
const [showToast, setShowToast] = useState(isSuccessFromRedirect);
|
const [showToast, setShowToast] = useState(isSuccessFromRedirect);
|
||||||
const [toastMessage, setToastMessage] = useState(
|
const [toastMessage, setToastMessage] = useState(
|
||||||
isSuccessFromRedirect ? '表单提交成功!我们会尽快与您联系。' : ''
|
isSuccessFromRedirect ? '表单提交成功!我们会尽快与您联系。' : ''
|
||||||
@@ -52,12 +56,6 @@ function ContactFormContent() {
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSuccessFromRedirect) {
|
|
||||||
setShowToast(true);
|
|
||||||
}
|
|
||||||
}, [isSuccessFromRedirect]);
|
|
||||||
|
|
||||||
const validateField = (field: keyof ContactFormData, value: string) => {
|
const validateField = (field: keyof ContactFormData, value: string) => {
|
||||||
try {
|
try {
|
||||||
contactFormSchema.shape[field].parse(value);
|
contactFormSchema.shape[field].parse(value);
|
||||||
@@ -153,7 +151,8 @@ function ContactFormContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
|
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '联系我们', href: '/contact' }]} />
|
||||||
{showToast && (
|
{showToast && (
|
||||||
<Toast
|
<Toast
|
||||||
message={toastMessage}
|
message={toastMessage}
|
||||||
@@ -166,16 +165,16 @@ function ContactFormContent() {
|
|||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '联系我们' }]} />
|
<PageNav items={[{ label: '联系我们' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Contact</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Contact</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
联系我们
|
联系我们
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
无论您有任何问题或合作意向,我们都很乐意与您交流
|
无论您有任何问题或合作意向,我们都很乐意与您交流
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -186,93 +185,93 @@ function ContactFormContent() {
|
|||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
|
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="lg:col-span-2 space-y-6"
|
className="lg:col-span-2 space-y-6"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[#1C1C1C] mb-6">联系方式</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-6">联系方式</h2>
|
||||||
<div className="space-y-4" data-testid="contact-info">
|
<div className="space-y-4" data-testid="contact-info">
|
||||||
<div className="flex items-start gap-4 group" data-testid="email-info">
|
<div className="flex items-start gap-4 group" data-testid="email-info">
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
|
||||||
<Mail className="w-5 h-5 text-[#C41E3A]" />
|
<Mail className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-[#595959] mb-1">邮箱</p>
|
<p className="text-sm text-[var(--color-text-muted)] mb-1">邮箱</p>
|
||||||
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[#1C1C1C] hover:text-[#C41E3A] transition-colors duration-200" data-testid="email-link">
|
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[var(--color-text-primary)] hover:text-[var(--color-brand-primary)] transition-colors duration-200" data-testid="email-link">
|
||||||
{COMPANY_INFO.email}
|
{COMPANY_INFO.email}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-4 group" data-testid="address-info">
|
<div className="flex items-start gap-4 group" data-testid="address-info">
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
|
||||||
<MapPin className="w-5 h-5 text-[#C41E3A]" />
|
<MapPin className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-[#595959] mb-1">地址</p>
|
<p className="text-sm text-[var(--color-text-muted)] mb-1">地址</p>
|
||||||
<p className="text-[#1C1C1C]" data-testid="address-text">{COMPANY_INFO.address}</p>
|
<p className="text-[var(--color-text-primary)]" data-testid="address-text">{COMPANY_INFO.address}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-[#E5E5E5]" data-testid="work-hours-card">
|
<div className="p-5 rounded-xl border border-[var(--color-border)]" data-testid="work-hours-card">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Clock className="w-4 h-4 text-[#C41E3A]" />
|
<Clock className="w-4 h-4 text-[var(--color-brand-primary)]" />
|
||||||
<h2 className="text-sm font-medium text-[#1C1C1C]">工作时间</h2>
|
<h2 className="text-sm font-medium text-[var(--color-text-primary)]">工作时间</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm" data-testid="work-hours-row">
|
<div className="flex justify-between text-sm" data-testid="work-hours-row">
|
||||||
<span className="text-[#595959]">周一至周五</span>
|
<span className="text-[var(--color-text-muted)]">周一至周五</span>
|
||||||
<span className="text-[#C41E3A] font-medium">9:00 - 18:00</span>
|
<span className="text-[var(--color-brand-primary)] font-medium">9:00 - 18:00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-[#E5E5E5]">
|
<div className="p-5 rounded-xl border border-[var(--color-border)]">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<HeadphonesIcon className="w-4 h-4 text-[#C41E3A]" />
|
<HeadphonesIcon className="w-4 h-4 text-[var(--color-brand-primary)]" />
|
||||||
<h2 className="text-sm font-medium text-[#1C1C1C]">我们的承诺</h2>
|
<h2 className="text-sm font-medium text-[var(--color-text-primary)]">我们的承诺</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
|
||||||
<p className="text-sm text-[#595959]">工作日 2 小时内快速响应您的咨询</p>
|
<p className="text-sm text-[var(--color-text-muted)]">工作日 2 小时内快速响应您的咨询</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
|
||||||
<p className="text-sm text-[#595959]">提供免费的业务咨询和方案评估服务</p>
|
<p className="text-sm text-[var(--color-text-muted)]">提供免费的业务咨询和方案评估服务</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
|
||||||
<p className="text-sm text-[#595959]">根据您的需求量身定制最优解决方案</p>
|
<p className="text-sm text-[var(--color-text-muted)]">根据您的需求量身定制最优解决方案</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
className="lg:col-span-3"
|
className="lg:col-span-3"
|
||||||
>
|
>
|
||||||
<div className="p-6 sm:p-8 rounded-xl border border-[#E5E5E5] bg-[#FAFAFA]">
|
<div className="p-6 sm:p-8 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] shadow-md">
|
||||||
<h2 className="text-lg font-semibold text-[#1C1C1C] mb-6">发送消息</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-6">发送消息</h2>
|
||||||
|
|
||||||
{isSubmitted ? (
|
{isSubmitted ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<CheckCircle2 className="w-8 h-8 text-white" />
|
<CheckCircle2 className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-semibold text-[#1C1C1C] mb-2">消息已发送</h4>
|
<h4 className="text-xl font-semibold text-[var(--color-text-primary)] mb-2">消息已发送</h4>
|
||||||
<p className="text-[#595959]">感谢您的留言,我们会尽快与您联系!</p>
|
<p className="text-[var(--color-text-muted)]">感谢您的留言,我们会尽快与您联系!</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
@@ -342,7 +341,7 @@ function ContactFormContent() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
data-testid="submit-button"
|
data-testid="submit-button"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full bg-[#C41E3A] hover:bg-[#A01830] text-white min-h-13 md:min-h-0"
|
className="w-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white min-h-13 md:min-h-0"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
@@ -371,8 +370,8 @@ function ContactFormContent() {
|
|||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
|
||||||
<div className="animate-pulse text-[#595959]">加载中...</div>
|
<div className="animate-pulse text-[var(--color-text-muted)]">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<ContactFormContent />
|
<ContactFormContent />
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ const CTASection = dynamic(
|
|||||||
|
|
||||||
function HomeContentV2() {
|
function HomeContentV2() {
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="min-h-screen bg-white">
|
<main id="main-content" className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<HeroSectionV2 />
|
<HeroSectionV2 />
|
||||||
<SocialProofSection />
|
|
||||||
<ProductMatrixSection />
|
<ProductMatrixSection />
|
||||||
<ChallengeSection />
|
<ChallengeSection />
|
||||||
|
<SocialProofSection />
|
||||||
<CTASection />
|
<CTASection />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<div className="page-transition-loader">
|
||||||
|
<div className="page-transition-loader__bar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
|
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { NEWS } from '@/lib/constants';
|
import { NEWS } from '@/lib/constants';
|
||||||
|
|
||||||
interface NewsDetailClientProps {
|
interface NewsDetailClientProps {
|
||||||
@@ -13,28 +14,30 @@ interface NewsDetailClientProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
const relatedNews = NEWS
|
const relatedNews = NEWS
|
||||||
.filter((n) => n.id !== news.id && n.category === news.category)
|
.filter((n) => n.id !== news.id && n.category === news.category)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<section className="pt-32 pb-12">
|
<section className="pt-32 pb-12">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '新闻动态', href: '/news' }, { label: news.title }]} />
|
<PageNav items={[{ label: '新闻动态', href: '/news' }, { label: news.title }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-4xl mt-6"
|
className="max-w-4xl mt-6"
|
||||||
>
|
>
|
||||||
<Badge className="mb-4 bg-[#C41E3A]/10 text-[#C41E3A] hover:bg-[#C41E3A]/20 border-0">
|
<Badge className="mb-4 bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/20 border-0">
|
||||||
{news.category}
|
{news.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-[#1C1C1C] mb-4 tracking-tight">
|
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4 tracking-tight">
|
||||||
{news.title}
|
{news.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-4 text-[#595959] text-sm">
|
<div className="flex items-center gap-4 text-[var(--color-text-muted)] text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
{news.date}
|
{news.date}
|
||||||
@@ -47,8 +50,8 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
|||||||
<section className="pb-16">
|
<section className="pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-4xl"
|
className="max-w-4xl"
|
||||||
@@ -62,24 +65,24 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-video bg-gradient-to-br from-[#FEF2F4] to-[#F5F5F5] rounded-xl mb-8 flex items-center justify-center">
|
<div className="aspect-video bg-gradient-to-br from-[var(--color-brand-primary-bg)] to-[var(--color-bg-tertiary)] rounded-xl mb-8 flex items-center justify-center">
|
||||||
<Newspaper className="w-16 h-16 text-[#C41E3A]/30" />
|
<Newspaper className="w-16 h-16 text-[var(--color-brand-primary)]/30" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-l-4 border-[#C41E3A] pl-6 mb-8">
|
<div className="border-l-4 border-[var(--color-brand-primary)] pl-6 mb-8">
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{news.excerpt}
|
{news.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[#1C1C1C] leading-relaxed whitespace-pre-line">
|
<div className="text-[var(--color-text-primary)] leading-relaxed whitespace-pre-line">
|
||||||
{news.content}
|
{news.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{relatedNews.length > 0 && (
|
{relatedNews.length > 0 && (
|
||||||
<div className="mt-16 pt-12 border-t border-[#E5E5E5]">
|
<div className="mt-16 pt-12 border-t border-[var(--color-border)]">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-8">相关新闻</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-8">相关新闻</h2>
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
{relatedNews.map((related) => (
|
{relatedNews.map((related) => (
|
||||||
<StaticLink key={related.id} href={`/news/${related.id}`}>
|
<StaticLink key={related.id} href={`/news/${related.id}`}>
|
||||||
@@ -92,16 +95,16 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
|||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-gradient-to-br from-[#FEF2F4] to-[#F5F5F5] flex items-center justify-center">
|
<div className="w-full h-full bg-gradient-to-br from-[var(--color-brand-primary-bg)] to-[var(--color-bg-tertiary)] flex items-center justify-center">
|
||||||
<Newspaper className="w-8 h-8 text-[#C41E3A]/30" />
|
<Newspaper className="w-8 h-8 text-[var(--color-brand-primary)]/30" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="mb-2 text-xs">{related.category}</Badge>
|
<Badge variant="secondary" className="mb-2 text-xs">{related.category}</Badge>
|
||||||
<h3 className="text-base font-semibold text-[#1C1C1C] mb-1 line-clamp-2 group-hover:text-[#C41E3A] transition-colors">
|
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-1 line-clamp-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||||
{related.title}
|
{related.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-[#595959] line-clamp-2">{related.excerpt}</p>
|
<p className="text-sm text-[var(--color-text-muted)] line-clamp-2">{related.excerpt}</p>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
))}
|
))}
|
||||||
@@ -117,7 +120,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
|
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white">
|
||||||
联系我们
|
联系我们
|
||||||
</Button>
|
</Button>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, ChangeEvent } from 'react';
|
import { useState, useMemo, ChangeEvent } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { NEWS, COMPANY_INFO } from '@/lib/constants';
|
import { NEWS, COMPANY_INFO } from '@/lib/constants';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -14,6 +15,8 @@ const categories = ['全部', '公司新闻', '研发动态'];
|
|||||||
const ITEMS_PER_PAGE = 9;
|
const ITEMS_PER_PAGE = 9;
|
||||||
|
|
||||||
export default function NewsListPage() {
|
export default function NewsListPage() {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -50,21 +53,21 @@ export default function NewsListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '新闻动态' }]} />
|
<PageNav items={[{ label: '新闻动态' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">News</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">News</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
新闻动态
|
新闻动态
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
了解{COMPANY_INFO.displayName}最新动态,把握行业发展脉搏
|
了解{COMPANY_INFO.displayName}最新动态,把握行业发展脉搏
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -74,8 +77,8 @@ export default function NewsListPage() {
|
|||||||
<section className="pb-16">
|
<section className="pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="mb-8 space-y-4"
|
className="mb-8 space-y-4"
|
||||||
@@ -88,7 +91,7 @@ export default function NewsListPage() {
|
|||||||
onClick={() => handleCategoryChange(category)}
|
onClick={() => handleCategoryChange(category)}
|
||||||
className={
|
className={
|
||||||
selectedCategory === category
|
selectedCategory === category
|
||||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
? 'bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -98,7 +101,7 @@ export default function NewsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#595959] w-5 h-5" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--color-text-muted)] w-5 h-5" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索新闻..."
|
placeholder="搜索新闻..."
|
||||||
@@ -112,8 +115,8 @@ export default function NewsListPage() {
|
|||||||
|
|
||||||
{paginatedNews.length === 0 ? (
|
{paginatedNews.length === 0 ? (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<Newspaper className="w-12 h-12 text-[#E5E5E5] mx-auto mb-4" />
|
<Newspaper className="w-12 h-12 text-[var(--color-border)] mx-auto mb-4" />
|
||||||
<p className="text-lg text-[#595959]">没有找到相关新闻</p>
|
<p className="text-lg text-[var(--color-text-muted)]">没有找到相关新闻</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -121,15 +124,15 @@ export default function NewsListPage() {
|
|||||||
{paginatedNews.map((newsItem, index) => (
|
{paginatedNews.map((newsItem, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={newsItem.id}
|
key={newsItem.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
>
|
>
|
||||||
<StaticLink href={`/news/${newsItem.id}`}>
|
<StaticLink href={`/news/${newsItem.id}`}>
|
||||||
<div className="group h-full bg-white rounded-xl border border-[#E5E5E5] overflow-hidden hover:border-[#C41E3A]/30 hover:shadow-md transition-all duration-300">
|
<div className="group h-full bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border)] overflow-hidden hover:border-[var(--color-brand-primary)]/30 hover:shadow-md transition-all duration-300">
|
||||||
{newsItem.image ? (
|
{newsItem.image ? (
|
||||||
<div className="aspect-video bg-[#F5F5F5] overflow-hidden">
|
<div className="aspect-video bg-[var(--color-bg-tertiary)] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={newsItem.image}
|
src={newsItem.image}
|
||||||
alt={newsItem.title}
|
alt={newsItem.title}
|
||||||
@@ -137,25 +140,25 @@ export default function NewsListPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-video bg-gradient-to-br from-[#FEF2F4] to-[#F5F5F5] flex items-center justify-center">
|
<div className="aspect-video bg-gradient-to-br from-[var(--color-brand-primary-bg)] to-[var(--color-bg-tertiary)] flex items-center justify-center">
|
||||||
<Newspaper className="w-12 h-12 text-[#C41E3A]/30" />
|
<Newspaper className="w-12 h-12 text-[var(--color-brand-primary)]/30" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Badge variant="secondary" className="text-xs">{newsItem.category}</Badge>
|
<Badge variant="secondary" className="text-xs">{newsItem.category}</Badge>
|
||||||
<div className="flex items-center gap-1 text-xs text-[#595959]">
|
<div className="flex items-center gap-1 text-xs text-[var(--color-text-muted)]">
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
{newsItem.date}
|
{newsItem.date}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2 line-clamp-2 group-hover:text-[#C41E3A] transition-colors">
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2 line-clamp-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||||
{newsItem.title}
|
{newsItem.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-[#595959] line-clamp-3 mb-4">
|
<p className="text-sm text-[var(--color-text-muted)] line-clamp-3 mb-4">
|
||||||
{newsItem.excerpt}
|
{newsItem.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center text-[#C41E3A] text-sm font-medium">
|
<div className="flex items-center text-[var(--color-brand-primary)] text-sm font-medium">
|
||||||
阅读更多
|
阅读更多
|
||||||
<ArrowRight className="ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
<ArrowRight className="ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +187,7 @@ export default function NewsListPage() {
|
|||||||
onClick={() => handlePageChange(page)}
|
onClick={() => handlePageChange(page)}
|
||||||
className={
|
className={
|
||||||
currentPage === page
|
currentPage === page
|
||||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
? 'bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,28 +34,28 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<div className="pt-32 pb-16">
|
<div className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '产品', href: '/products' }, { label: product.title }]} />
|
<PageNav items={[{ label: '产品', href: '/products' }, { label: product.title }]} />
|
||||||
<div className="max-w-4xl mt-8">
|
<div className="max-w-4xl mt-8">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<p className="text-sm font-medium text-[#C41E3A] tracking-wide uppercase">{product.category}</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] tracking-wide uppercase">{product.category}</p>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-medium px-3 py-1 rounded-full border"
|
className="text-xs font-medium px-3 py-1 rounded-full border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'rgba(196, 30, 58, 0.08)',
|
backgroundColor: 'rgba(var(--color-brand-primary-rgb), 0.08)',
|
||||||
color: '#C41E3A',
|
color: 'var(--color-brand-primary)',
|
||||||
borderColor: 'rgba(196, 30, 58, 0.15)',
|
borderColor: 'rgba(var(--color-brand-primary-rgb), 0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{product.status}
|
{product.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
{product.title}
|
{product.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#595959] leading-relaxed">
|
<p className="text-xl text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{product.description}
|
{product.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,92 +64,92 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
|
|
||||||
<div className="container-wide pb-20">
|
<div className="container-wide pb-20">
|
||||||
<div className="max-w-4xl space-y-16">
|
<div className="max-w-4xl space-y-16">
|
||||||
<section>
|
<section aria-labelledby="product-overview">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6">产品概述</h2>
|
<h2 id="product-overview" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">产品概述</h2>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{product.overview}
|
{product.overview}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section aria-labelledby="product-features">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
|
<h2 id="product-features" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
|
||||||
<Zap className="w-6 h-6 text-[#C41E3A]" />
|
<Zap className="w-6 h-6 text-[var(--color-brand-primary)]" />
|
||||||
规划功能
|
规划功能
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{product.features.map((feature, index) => (
|
{product.features.map((feature, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-3 p-4 bg-[#F5F5F5] rounded-lg hover:bg-[#FEF2F4] transition-colors"
|
className="flex items-start gap-3 p-4 bg-[var(--color-bg-tertiary)] rounded-lg hover:bg-[var(--color-brand-primary-bg)] transition-colors"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-5 h-5 text-[#C41E3A] mt-0.5 shrink-0" />
|
<CheckCircle2 className="w-5 h-5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||||||
<span className="text-[#1C1C1C] text-sm">{feature}</span>
|
<span className="text-[var(--color-text-primary)] text-sm">{feature}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section aria-labelledby="product-benefits">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
|
<h2 id="product-benefits" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
|
||||||
<Target className="w-6 h-6 text-[#C41E3A]" />
|
<Target className="w-6 h-6 text-[var(--color-brand-primary)]" />
|
||||||
产品优势
|
产品优势
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{product.benefits.map((benefit, index) => (
|
{product.benefits.map((benefit, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[#C41E3A] bg-[#FAFAFA]"
|
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
|
||||||
>
|
>
|
||||||
<span className="text-[#1C1C1C] font-medium text-sm">{benefit}</span>
|
<span className="text-[var(--color-text-primary)] font-medium text-sm">{benefit}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section aria-labelledby="product-process">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
|
<h2 id="product-process" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
|
||||||
<Layers className="w-6 h-6 text-[#C41E3A]" />
|
<Layers className="w-6 h-6 text-[var(--color-brand-primary)]" />
|
||||||
预期实施流程
|
预期实施流程
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{product.process.map((step, index) => (
|
{product.process.map((step, index) => (
|
||||||
<div key={index} className="flex items-start gap-4">
|
<div key={index} className="flex items-start gap-4">
|
||||||
<div className="w-8 h-8 bg-[#C41E3A] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
|
<div className="w-8 h-8 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#1C1C1C] pt-1">{step}</p>
|
<p className="text-[var(--color-text-primary)] pt-1">{step}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section aria-labelledby="product-specs">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6">技术规格</h2>
|
<h2 id="product-specs" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">技术规格</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-3">
|
<div className="grid md:grid-cols-2 gap-3">
|
||||||
{product.specs.map((spec, index) => (
|
{product.specs.map((spec, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center gap-3 p-3 bg-[#F5F5F5] rounded-lg"
|
className="flex items-center gap-3 p-3 bg-[var(--color-bg-tertiary)] rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full" />
|
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full" />
|
||||||
<span className="text-[#1C1C1C] text-sm">{spec}</span>
|
<span className="text-[var(--color-text-primary)] text-sm">{spec}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-[#FEF2F4] rounded-2xl p-8 md:p-10">
|
<section className="bg-[var(--color-brand-primary-bg)] rounded-2xl p-8 md:p-10" aria-labelledby="product-pricing">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center shrink-0">
|
<div className="w-12 h-12 bg-[var(--color-brand-primary)] rounded-xl flex items-center justify-center shrink-0">
|
||||||
<FlaskConical className="w-6 h-6 text-white" />
|
<FlaskConical className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-3">定价待公布</h2>
|
<h2 id="product-pricing" className="text-2xl font-bold text-[var(--color-text-primary)] mb-3">定价待公布</h2>
|
||||||
<p className="text-[#595959] leading-relaxed mb-6">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-6">
|
||||||
本产品正在研发中,正式定价将在产品发布时公布。如果您对产品方向感兴趣,欢迎预约早期体验,您的反馈将帮助我们打造更贴合需求的产品。
|
本产品正在研发中,正式定价将在产品发布时公布。如果您对产品方向感兴趣,欢迎预约早期体验,您的反馈将帮助我们打造更贴合需求的产品。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
|
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
预约体验
|
预约体验
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
@@ -163,11 +163,11 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
|
<div className="flex justify-center gap-4 pt-8 border-t border-[var(--color-border-primary)]">
|
||||||
<Button variant="outline" size="lg" asChild>
|
<Button variant="outline" size="lg" asChild>
|
||||||
<StaticLink href="/contact">关注进展</StaticLink>
|
<StaticLink href="/contact">关注进展</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
|
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
预约体验
|
预约体验
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
|||||||
@@ -2,29 +2,32 @@
|
|||||||
|
|
||||||
import { PRODUCTS } from '@/lib/constants';
|
import { PRODUCTS } from '@/lib/constants';
|
||||||
import { ProductCard } from '@/components/ui/product-card';
|
import { ProductCard } from '@/components/ui/product-card';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight } from 'lucide-react';
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
import { CTASection } from '@/components/sections/cta-section';
|
||||||
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
|
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '产品', href: '/products' }]} />
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '产品' }]} />
|
<PageNav items={[{ label: '产品' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Products</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Products</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
产品
|
产品
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们正在打造的企业级产品,致力于助力企业高效运营,实现数字化转型
|
我们正在打造的企业级产品,致力于助力企业高效运营,实现数字化转型
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -33,7 +36,7 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
<section className="pb-20">
|
<section className="pb-20">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{PRODUCTS.map((product, index) => (
|
{PRODUCTS.map((product, index) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
@@ -48,26 +51,14 @@ export default function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-[#F5F5F5] py-20">
|
<CTASection
|
||||||
<div className="container-wide text-center">
|
title="期待与您共同打磨产品"
|
||||||
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-6">
|
description="我们的产品正在研发中,如果您对产品方向有建议或希望参与早期体验,欢迎联系我们"
|
||||||
期待与您共同打磨产品
|
primaryLabel="预约体验"
|
||||||
</h2>
|
primaryHref="/contact"
|
||||||
<p className="text-lg text-[#595959] mb-8 max-w-2xl mx-auto">
|
secondaryLabel="了解方案"
|
||||||
我们的产品正在研发中,如果您对产品方向有建议或希望参与早期体验,欢迎联系我们
|
secondaryHref="/solutions"
|
||||||
</p>
|
/>
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<StaticLink href="/contact">
|
|
||||||
预约体验
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,16 +72,16 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
|||||||
const serviceOutcomes = outcomes[service.id] ?? [];
|
const serviceOutcomes = outcomes[service.id] ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<div className="pt-32 pb-16">
|
<div className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
|
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
|
||||||
<div className="max-w-4xl mt-8">
|
<div className="max-w-4xl mt-8">
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Services</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Services</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
{service.title}
|
{service.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#595959] leading-relaxed">
|
<p className="text-xl text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{service.description}
|
{service.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,19 +92,19 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
|||||||
<div className="max-w-4xl space-y-16">
|
<div className="max-w-4xl space-y-16">
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
|
||||||
<MessageCircle className="w-5 h-5 text-[#C41E3A]" />
|
<MessageCircle className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C]">您可能面临的挑战</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]">您可能面临的挑战</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{serviceChallenges.map((challenge, index) => (
|
{serviceChallenges.map((challenge, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="p-4 bg-[#F5F5F5] rounded-lg hover:bg-[#FEF2F4] transition-colors"
|
className="p-4 bg-[var(--color-bg-tertiary)] rounded-lg border border-[var(--color-border-primary)] hover:border-[var(--color-brand-primary)]/30 hover:bg-[var(--color-brand-primary-bg)] transition-colors"
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-[#1C1C1C] mb-1 text-sm">{challenge.title}</h3>
|
<h3 className="font-semibold text-[var(--color-text-primary)] mb-1 text-sm">{challenge.title}</h3>
|
||||||
<p className="text-sm text-[#595959]">{challenge.description}</p>
|
<p className="text-sm text-[var(--color-text-muted)]">{challenge.description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,19 +112,19 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
|
||||||
<Target className="w-5 h-5 text-[#C41E3A]" />
|
<Target className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C]">我们如何帮助您</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]">我们如何帮助您</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed mb-6">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed mb-6">
|
||||||
{service.overview}
|
{service.overview}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{service.features.map((feature, index) => (
|
{service.features.map((feature, index) => (
|
||||||
<div key={index} className="flex items-start gap-3">
|
<div key={index} className="flex items-start gap-3">
|
||||||
<CheckCircle2 className="w-5 h-5 text-[#C41E3A] mt-0.5 shrink-0" />
|
<CheckCircle2 className="w-5 h-5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||||||
<span className="text-[#1C1C1C] text-sm">{feature}</span>
|
<span className="text-[var(--color-text-primary)] text-sm">{feature}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -132,18 +132,18 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
|
||||||
<Clock className="w-5 h-5 text-[#C41E3A]" />
|
<Clock className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C]">服务流程</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]">服务流程</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{service.process.map((step, index) => (
|
{service.process.map((step, index) => (
|
||||||
<div key={index} className="flex items-start gap-4">
|
<div key={index} className="flex items-start gap-4">
|
||||||
<div className="w-8 h-8 bg-[#C41E3A] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
|
<div className="w-8 h-8 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#1C1C1C] pt-1">{step}</p>
|
<p className="text-[var(--color-text-primary)] pt-1">{step}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,37 +151,37 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
|
||||||
<TrendingUp className="w-5 h-5 text-[#C41E3A]" />
|
<TrendingUp className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C]">您将获得的改变</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]">您将获得的改变</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid sm:grid-cols-3 gap-4">
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
{serviceOutcomes.map((outcome, index) => (
|
{serviceOutcomes.map((outcome, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="p-6 bg-[#F5F5F5] rounded-lg text-center hover:bg-[#FEF2F4] transition-colors"
|
className="p-6 bg-[var(--color-bg-tertiary)] rounded-lg text-center hover:bg-[var(--color-brand-primary-bg)] transition-colors"
|
||||||
>
|
>
|
||||||
<div className="text-3xl font-bold text-[#C41E3A] mb-2">
|
<div className="text-3xl font-bold text-[var(--color-brand-primary)] mb-2">
|
||||||
{outcome.value}
|
{outcome.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-[#595959]">{outcome.label}</div>
|
<div className="text-sm text-[var(--color-text-muted)]">{outcome.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 p-4 bg-[#F5F5F5] rounded-lg">
|
<div className="mt-4 p-4 bg-[var(--color-bg-tertiary)] rounded-lg">
|
||||||
<p className="text-sm text-[#595959]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
{service.benefits.join(';')}
|
{service.benefits.join(';')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
|
<div className="flex justify-center gap-4 pt-8 border-t border-[var(--color-border-primary)]">
|
||||||
<StaticLink href="/services">
|
<StaticLink href="/services">
|
||||||
<Button variant="outline" size="lg">查看其他服务</Button>
|
<Button variant="outline" size="lg">查看其他服务</Button>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
|
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white">
|
||||||
开始您的转型之旅
|
开始您的转型之旅
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,25 +5,28 @@ import { StaticLink } from '@/components/ui/static-link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowRight, ArrowUpRight } from 'lucide-react';
|
import { ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '服务' }]} />
|
<PageNav items={[{ label: '服务' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Services</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Services</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
服务
|
服务
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
专业技术团队,为您提供全方位的数字化解决方案
|
专业技术团队,为您提供全方位的数字化解决方案
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -36,32 +39,32 @@ export default function ServicesPage() {
|
|||||||
{SERVICES.map((service, index) => (
|
{SERVICES.map((service, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={service.id}
|
key={service.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.4, delay: index * 0.1, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.4, delay: index * 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
>
|
>
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href={`/services/${service.id}`}
|
href={`/services/${service.id}`}
|
||||||
className="group block p-6 rounded-xl border border-[#E5E5E5] bg-white hover:border-[#C41E3A]/40 hover:shadow-lg transition-all duration-300 h-full"
|
className="group block p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-brand-primary)]/40 hover:shadow-lg transition-all duration-300 h-full"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-[#FEF2F4] text-[#C41E3A] text-sm font-bold">
|
<span className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-bold">
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<ArrowUpRight className="w-5 h-5 text-[#595959] group-hover:text-[#C41E3A] transition-colors" />
|
<ArrowUpRight className="w-5 h-5 text-[var(--color-text-muted)] group-hover:text-[var(--color-brand-primary)] transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2 group-hover:text-[#C41E3A] transition-colors">
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||||
{service.title}
|
{service.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-[#595959] leading-relaxed mb-4">
|
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
{service.description}
|
{service.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{service.features.slice(0, 3).map((feature, idx) => (
|
{service.features.slice(0, 3).map((feature, idx) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={idx}
|
||||||
className="inline-flex items-center text-xs px-2 py-1 bg-[#F5F5F5] text-[#595959] rounded"
|
className="inline-flex items-center text-xs px-2 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-muted)] rounded"
|
||||||
>
|
>
|
||||||
{feature.split(':')[0]}
|
{feature.split(':')[0]}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,21 +77,21 @@ export default function ServicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-[#F5F5F5] py-20">
|
<section className="bg-[var(--color-bg-tertiary)] py-20">
|
||||||
<div className="container-wide text-center">
|
<div className="container-wide text-center">
|
||||||
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-6">
|
<h2 className="text-3xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||||
准备开始您的数字化转型之旅?
|
准备开始您的数字化转型之旅?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-[#595959] mb-8 max-w-2xl mx-auto">
|
<p className="text-lg text-[var(--color-text-muted)] mb-8 max-w-2xl mx-auto">
|
||||||
让我们与您同行,共创美好未来
|
让我们与您同行,共创美好未来
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
|
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
立即咨询
|
获取服务报价
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
@@ -14,25 +15,27 @@ interface SolutionDetailClientProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SolutionDetailClient({ solution, relatedProducts }: SolutionDetailClientProps) {
|
export function SolutionDetailClient({ solution, relatedProducts }: SolutionDetailClientProps) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<div className="pt-32 pb-16">
|
<div className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '解决方案', href: '/solutions' }, { label: solution.title }]} />
|
<PageNav items={[{ label: '解决方案', href: '/solutions' }, { label: solution.title }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-4xl mt-8"
|
className="max-w-4xl mt-8"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">
|
||||||
{solution.industry}
|
{solution.industry}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-4 tracking-tight">
|
||||||
{solution.title}
|
{solution.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#595959] mb-2">{solution.subtitle}</p>
|
<p className="text-xl text-[var(--color-text-muted)] mb-2">{solution.subtitle}</p>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{solution.description}
|
{solution.description}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -42,44 +45,44 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
|||||||
<div className="container-wide pb-20">
|
<div className="container-wide pb-20">
|
||||||
<div className="max-w-4xl space-y-16">
|
<div className="max-w-4xl space-y-16">
|
||||||
<motion.section
|
<motion.section
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
|
||||||
<AlertTriangle className="w-6 h-6 text-[#C41E3A]" />
|
<AlertTriangle className="w-6 h-6 text-[var(--color-brand-primary)]" />
|
||||||
行业痛点
|
行业痛点
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{solution.challenges.map((challenge, index) => (
|
{solution.challenges.map((challenge, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[#C41E3A] bg-[#FAFAFA]"
|
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
|
||||||
>
|
>
|
||||||
<span className="text-[#1C1C1C] text-sm">{challenge}</span>
|
<span className="text-[var(--color-text-primary)] text-sm">{challenge}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
|
|
||||||
<motion.section
|
<motion.section
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
|
||||||
<CheckCircle2 className="w-6 h-6 text-[#C41E3A]" />
|
<CheckCircle2 className="w-6 h-6 text-[var(--color-brand-primary)]" />
|
||||||
解决方案
|
解决方案
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{solution.solutions.map((sol, index) => (
|
{solution.solutions.map((sol, index) => (
|
||||||
<div key={index} className="flex items-start gap-4">
|
<div key={index} className="flex items-start gap-4">
|
||||||
<div className="w-8 h-8 bg-[#C41E3A] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
|
<div className="w-8 h-8 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#1C1C1C] pt-1 leading-relaxed">{sol}</p>
|
<p className="text-[var(--color-text-primary)] pt-1 leading-relaxed">{sol}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -87,13 +90,13 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
|||||||
|
|
||||||
{relatedProducts.length > 0 && (
|
{relatedProducts.length > 0 && (
|
||||||
<motion.section
|
<motion.section
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
|
||||||
<Package className="w-6 h-6 text-[#C41E3A]" />
|
<Package className="w-6 h-6 text-[var(--color-brand-primary)]" />
|
||||||
相关产品
|
相关产品
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
@@ -101,26 +104,26 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
|||||||
<StaticLink
|
<StaticLink
|
||||||
key={product.id}
|
key={product.id}
|
||||||
href={`/products/${product.id}`}
|
href={`/products/${product.id}`}
|
||||||
className="group block p-6 rounded-xl border border-[#E5E5E5] bg-white hover:border-[#C41E3A]/40 hover:shadow-lg transition-all duration-300"
|
className="group block p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-brand-primary)]/40 hover:shadow-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] group-hover:text-[#C41E3A] transition-colors">
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||||
{product.title}
|
{product.title}
|
||||||
</h3>
|
</h3>
|
||||||
{product.status && (
|
{product.status && (
|
||||||
<span
|
<span
|
||||||
className="text-xs font-medium px-3 py-1 rounded-full border shrink-0"
|
className="text-xs font-medium px-3 py-1 rounded-full border shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: product.status === '研发中' ? 'rgba(196, 30, 58, 0.08)' : product.status === '内测中' ? 'rgba(217, 119, 6, 0.08)' : 'rgba(22, 163, 74, 0.08)',
|
backgroundColor: product.status === '研发中' ? 'rgba(var(--color-brand-primary-rgb), 0.08)' : product.status === '内测中' ? 'rgba(var(--color-warning-rgb), 0.08)' : 'rgba(var(--color-success-rgb), 0.08)',
|
||||||
color: product.status === '研发中' ? '#C41E3A' : product.status === '内测中' ? '#D97706' : '#16A34A',
|
color: product.status === '研发中' ? 'var(--color-brand-primary)' : product.status === '内测中' ? 'var(--color-warning)' : 'var(--color-success)',
|
||||||
borderColor: product.status === '研发中' ? 'rgba(196, 30, 58, 0.15)' : product.status === '内测中' ? 'rgba(217, 119, 6, 0.15)' : 'rgba(22, 163, 74, 0.15)',
|
borderColor: product.status === '研发中' ? 'rgba(var(--color-brand-primary-rgb), 0.15)' : product.status === '内测中' ? 'rgba(var(--color-warning-rgb), 0.15)' : 'rgba(var(--color-success-rgb), 0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{product.status}
|
{product.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#595959] leading-relaxed">
|
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{product.description}
|
{product.description}
|
||||||
</p>
|
</p>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
@@ -129,13 +132,13 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
|||||||
</motion.section>
|
</motion.section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
|
<div className="flex justify-center gap-4 pt-8 border-t border-[var(--color-border-primary)]">
|
||||||
<Button variant="outline" size="lg" asChild>
|
<Button variant="outline" size="lg" asChild>
|
||||||
<StaticLink href="/contact">联系我们</StaticLink>
|
<StaticLink href="/contact">联系我们</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
|
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
立即咨询
|
预约方案演示
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
|
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
|
||||||
import { MethodologySection } from '@/components/sections/methodology-section';
|
import { MethodologySection } from '@/components/sections/methodology-section';
|
||||||
|
import { CTASection } from '@/components/sections/cta-section';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
|
|
||||||
const modules = [
|
const modules = [
|
||||||
{
|
{
|
||||||
@@ -19,8 +21,10 @@ const modules = [
|
|||||||
],
|
],
|
||||||
values: ['行业趋势洞察报告', '数字化转型成熟度评估', '个性化实施路径规划'],
|
values: ['行业趋势洞察报告', '数字化转型成熟度评估', '个性化实施路径规划'],
|
||||||
cta: '预约一次免费诊断',
|
cta: '预约一次免费诊断',
|
||||||
ctaVariant: 'default' as const,
|
|
||||||
ctaHref: '/contact',
|
ctaHref: '/contact',
|
||||||
|
accentColor: 'var(--color-brand-primary)',
|
||||||
|
accentBg: 'var(--color-brand-primary-bg)',
|
||||||
|
accentBorder: 'border-l-[var(--color-brand-primary)]',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
@@ -33,8 +37,10 @@ const modules = [
|
|||||||
],
|
],
|
||||||
values: ['业务场景深度调研', '技术方案定制开发', '敏捷交付快速迭代'],
|
values: ['业务场景深度调研', '技术方案定制开发', '敏捷交付快速迭代'],
|
||||||
cta: '了解技术方案',
|
cta: '了解技术方案',
|
||||||
ctaVariant: 'outline' as const,
|
|
||||||
ctaHref: '/products',
|
ctaHref: '/products',
|
||||||
|
accentColor: 'var(--color-accent-blue)',
|
||||||
|
accentBg: 'rgba(37, 99, 235, 0.08)',
|
||||||
|
accentBorder: 'border-l-[var(--color-accent-blue)]',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@@ -47,28 +53,33 @@ const modules = [
|
|||||||
],
|
],
|
||||||
values: ['专属客户成功经理', '季度业务复盘会', '7×24小时响应通道'],
|
values: ['专属客户成功经理', '季度业务复盘会', '7×24小时响应通道'],
|
||||||
cta: '了解陪跑服务',
|
cta: '了解陪跑服务',
|
||||||
ctaVariant: 'default' as const,
|
|
||||||
ctaHref: '/services',
|
ctaHref: '/services',
|
||||||
|
accentColor: 'var(--color-success)',
|
||||||
|
accentBg: 'rgba(22, 163, 74, 0.08)',
|
||||||
|
accentBorder: 'border-l-[var(--color-success)]',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SolutionsPage() {
|
export default function SolutionsPage() {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
|
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '解决方案', href: '/solutions' }]} />
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '解决方案' }]} />
|
<PageNav items={[{ label: '解决方案' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Solutions</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Solutions</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
三种角色,一种身份——您的成长伙伴
|
三种角色,一种身份——您的成长伙伴
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们以伙伴的身份,陪您走过数字化转型的每一步
|
我们以伙伴的身份,陪您走过数字化转型的每一步
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -84,57 +95,65 @@ export default function SolutionsPage() {
|
|||||||
<motion.section
|
<motion.section
|
||||||
key={index}
|
key={index}
|
||||||
id={anchorIds[index]}
|
id={anchorIds[index]}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
className="p-8 md:p-12 rounded-xl border border-[#E5E5E5] bg-white hover:border-[#C41E3A]/30 transition-colors"
|
className={`p-8 md:p-12 rounded-xl border border-[var(--color-border-primary)] border-l-4 ${module.accentBorder} bg-[var(--color-bg-primary)] hover:border-[var(--color-border-primary)] transition-colors`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4 mb-6">
|
<div className="flex items-start gap-4 mb-6">
|
||||||
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center shrink-0">
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: module.accentBg }}>
|
||||||
<module.icon className="w-6 h-6 text-white" />
|
<module.icon className="w-6 h-6" style={{ color: module.accentColor }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-1">
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-1">
|
||||||
模块{['一', '二', '三'][index]}:{module.title}
|
模块{['一', '二', '三'][index]}:{module.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#595959]">{module.subtitle}</p>
|
<p className="text-[var(--color-text-muted)]">{module.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-8">
|
<div className="space-y-4 mb-8">
|
||||||
{module.paragraphs.map((p, i) => (
|
{module.paragraphs.map((p, i) => (
|
||||||
<p key={i} className="text-[#1C1C1C] leading-relaxed">{p}</p>
|
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed">{p}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4 flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-5 h-5 text-[#C41E3A]" />
|
<CheckCircle2 className="w-5 h-5" style={{ color: module.accentColor }} />
|
||||||
核心价值点
|
核心价值点
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid md:grid-cols-3 gap-3">
|
<div className="grid md:grid-cols-3 gap-3">
|
||||||
{module.values.map((value, i) => (
|
{module.values.map((value, i) => (
|
||||||
<div key={i} className="flex items-start gap-2 p-3 bg-[#F5F5F5] rounded-lg">
|
<div key={i} className="flex items-start gap-2 p-3 rounded-lg" style={{ backgroundColor: module.accentBg }}>
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
<div className="w-1.5 h-1.5 rounded-full mt-2 shrink-0" style={{ backgroundColor: module.accentColor }} />
|
||||||
<span className="text-sm text-[#1C1C1C]">{value}</span>
|
<span className="text-sm text-[var(--color-text-primary)]">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button
|
<StaticLink
|
||||||
size="lg"
|
href={module.ctaHref}
|
||||||
variant={module.ctaVariant}
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg border-2 font-medium transition-all duration-200 hover:text-white"
|
||||||
className={module.ctaVariant === 'default' ? 'bg-[#C41E3A] hover:bg-[#A01830] text-white' : 'border-[#C41E3A] text-[#C41E3A] hover:bg-[#C41E3A] hover:text-white'}
|
style={{
|
||||||
asChild
|
borderColor: module.accentColor,
|
||||||
|
color: module.accentColor,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = module.accentColor;
|
||||||
|
e.currentTarget.style.color = '#fff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
e.currentTarget.style.color = module.accentColor;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<StaticLink href={module.ctaHref}>
|
{module.cta}
|
||||||
{module.cta}
|
<ArrowRight className="w-4 h-4" />
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
</StaticLink>
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
);
|
);
|
||||||
@@ -145,27 +164,14 @@ export default function SolutionsPage() {
|
|||||||
|
|
||||||
<MethodologySection />
|
<MethodologySection />
|
||||||
|
|
||||||
<section className="bg-[#F5F5F5] py-20">
|
<CTASection
|
||||||
<div className="container-wide text-center">
|
title="准备开始您的数字化转型之旅?"
|
||||||
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-6">
|
description="无论您处于哪个阶段,我们都能为您提供合适的解决方案"
|
||||||
准备开始您的数字化转型之旅?
|
primaryLabel="立即咨询"
|
||||||
</h2>
|
primaryHref="/contact"
|
||||||
<p className="text-lg text-[#595959] mb-8 max-w-2xl mx-auto">
|
secondaryLabel="联系我们"
|
||||||
无论您处于哪个阶段,我们都能为您提供合适的解决方案
|
secondaryHref="/contact"
|
||||||
</p>
|
/>
|
||||||
<div className="flex justify-center gap-4">
|
|
||||||
<Button size="lg" variant="outline" asChild>
|
|
||||||
<StaticLink href="/contact">联系我们</StaticLink>
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
|
|
||||||
<StaticLink href="/contact">
|
|
||||||
立即咨询
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Shield, Building2, Users, Code, Target, ArrowRight } from 'lucide-react';
|
import { Shield, Building2, Users, Code, Target, ArrowRight } from 'lucide-react';
|
||||||
@@ -35,22 +36,24 @@ const TEAM_PILLARS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function TeamClient() {
|
export function TeamClient() {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '核心团队' }]} />
|
<PageNav items={[{ label: '核心团队' }]} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-3xl"
|
className="max-w-3xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Team</p>
|
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Team</p>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
|
||||||
核心团队
|
核心团队
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#595959] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
核心团队从事技术咨询、企业数字化等行业 12 年+,开发团队成员来自于多个大型传统 IT 企业
|
核心团队从事技术咨询、企业数字化等行业 12 年+,开发团队成员来自于多个大型传统 IT 企业
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -61,21 +64,21 @@ export function TeamClient() {
|
|||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<div className="max-w-5xl mx-auto space-y-8">
|
<div className="max-w-5xl mx-auto space-y-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="p-8 rounded-xl border border-[#E5E5E5]"
|
className="p-8 rounded-xl border border-[var(--color-border-primary)]"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center">关于我们的团队</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 text-center">关于我们的团队</h2>
|
||||||
<div className="space-y-4 max-w-3xl mx-auto text-center">
|
<div className="space-y-4 max-w-3xl mx-auto text-center">
|
||||||
<p className="text-[#595959] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们的核心团队长期从事<span className="text-[#C41E3A] font-medium">技术咨询</span>、<span className="text-[#C41E3A] font-medium">企业数字化</span>等行业,拥有 12 年以上的深厚积累。
|
我们的核心团队长期从事<span className="text-[var(--color-brand-primary)] font-medium">技术咨询</span>、<span className="text-[var(--color-brand-primary)] font-medium">企业数字化</span>等行业,拥有 12 年以上的深厚积累。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#595959] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
开发团队成员来自于多个<span className="text-[#C41E3A] font-medium">大型传统 IT 企业</span>,具备扎实的工程能力和规范化的交付经验。
|
开发团队成员来自于多个<span className="text-[var(--color-brand-primary)] font-medium">大型传统 IT 企业</span>,具备扎实的工程能力和规范化的交付经验。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#595959] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们相信,优秀的技术咨询不仅需要过硬的技术能力,更需要深入理解客户的业务场景和真实需求。
|
我们相信,优秀的技术咨询不仅需要过硬的技术能力,更需要深入理解客户的业务场景和真实需求。
|
||||||
每一位成员都是既懂技术又懂业务的复合型人才。
|
每一位成员都是既懂技术又懂业务的复合型人才。
|
||||||
</p>
|
</p>
|
||||||
@@ -83,31 +86,31 @@ export function TeamClient() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-8 text-center">团队优势</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-8 text-center">团队优势</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{TEAM_PILLARS.map((item, idx) => {
|
{TEAM_PILLARS.map((item, idx) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item.title}
|
key={item.title}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: idx * 0.1 }}
|
transition={{ duration: 0.5, delay: idx * 0.1 }}
|
||||||
className={idx >= 3 ? 'md:col-span-1 lg:col-start-1' : ''}
|
className={idx >= 3 ? 'md:col-span-1 lg:col-start-1' : ''}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4 p-6 rounded-xl border border-[#E5E5E5] hover:border-[#C41E3A]/30 transition-colors h-full">
|
<div className="flex items-start gap-4 p-6 rounded-xl border border-[var(--color-border-primary)] hover:border-[var(--color-brand-primary)]/30 transition-colors h-full">
|
||||||
<div className="w-10 h-10 rounded-lg bg-[#FEF2F4] flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] flex items-center justify-center shrink-0">
|
||||||
<Icon className="w-5 h-5 text-[#C41E3A]" />
|
<Icon className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-[#1C1C1C] mb-1">{item.title}</h3>
|
<h3 className="font-semibold text-[var(--color-text-primary)] mb-1">{item.title}</h3>
|
||||||
<p className="text-sm text-[#595959]">{item.description}</p>
|
<p className="text-sm text-[var(--color-text-muted)]">{item.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -117,14 +120,14 @@ export function TeamClient() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{...fadeUp}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-center pt-8"
|
className="text-center pt-8"
|
||||||
>
|
>
|
||||||
<p className="text-lg text-[#595959] mb-6">想与我们的团队交流?</p>
|
<p className="text-lg text-[var(--color-text-muted)] mb-6">想与我们的团队交流?</p>
|
||||||
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
|
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
|
||||||
<StaticLink href="/contact">
|
<StaticLink href="/contact">
|
||||||
联系我们
|
联系我们
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, type Variants } from 'framer-motion';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
|
const pageVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 8,
|
||||||
|
},
|
||||||
|
enter: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.35,
|
||||||
|
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -4,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarketingTemplate({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
if (shouldReduceMotion) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="enter"
|
||||||
|
exit="exit"
|
||||||
|
variants={pageVariants}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-24
@@ -17,32 +17,32 @@ export default function Error({
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
|
||||||
<div className="container-wide px-4 py-20">
|
<div className="container-wide px-4 py-20">
|
||||||
<div className="max-w-2xl mx-auto text-center">
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="w-24 h-24 bg-[#C41E3A]/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-24 h-24 bg-[var(--color-brand-primary-bg)] rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<AlertTriangle className="w-12 h-12 text-[#C41E3A]" />
|
<AlertTriangle className="w-12 h-12 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32 h-1 bg-[#C41E3A] mx-auto" />
|
<div className="w-32 h-1 bg-[var(--color-brand-primary)] mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-[#1C1C1C] mb-4">
|
<h1 className="text-3xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||||
出现了一些问题
|
出现了一些问题
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-lg text-[#5C5C5C] mb-6 leading-relaxed">
|
<p className="text-lg text-[var(--color-text-placeholder)] mb-6 leading-relaxed">
|
||||||
很抱歉,我们遇到了一个意外错误。
|
很抱歉,我们遇到了一个意外错误。
|
||||||
请尝试刷新页面,或返回首页继续浏览。
|
请尝试刷新页面,或返回首页继续浏览。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error.message && (
|
{error.message && (
|
||||||
<div className="bg-[#FAFAFA] border border-[#E5E5E5] rounded-lg p-4 mb-8 text-left">
|
<div className="bg-[var(--color-bg-section)] border border-[var(--color-border-primary)] rounded-lg p-4 mb-8 text-left">
|
||||||
<p className="text-sm text-[#5C5C5C] font-mono">
|
<p className="text-sm text-[var(--color-text-placeholder)] font-mono">
|
||||||
错误信息: {error.message}
|
错误信息: {error.message}
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{error.digest && (
|
||||||
<p className="text-xs text-[#5C5C5C] mt-2 font-mono">
|
<p className="text-xs text-[var(--color-text-placeholder)] mt-2 font-mono">
|
||||||
错误ID: {error.digest}
|
错误ID: {error.digest}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -53,7 +53,7 @@ export default function Error({
|
|||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
|
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-5 h-5 mr-2" />
|
<RefreshCw className="w-5 h-5 mr-2" />
|
||||||
重试
|
重试
|
||||||
@@ -71,43 +71,43 @@ export default function Error({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#FAFAFA] rounded-lg p-8">
|
<div className="bg-[var(--color-bg-section)] rounded-lg p-8">
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-6">
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-6">
|
||||||
需要帮助?
|
需要帮助?
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href="/contact"
|
href="/contact"
|
||||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||||
<AlertTriangle className="w-5 h-5 text-[#C41E3A]" />
|
<AlertTriangle className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold text-[#1C1C1C]">联系我们</div>
|
<div className="font-semibold text-[var(--color-text-primary)]">联系我们</div>
|
||||||
<div className="text-sm text-[#5C5C5C]">获取技术支持</div>
|
<div className="text-sm text-[var(--color-text-placeholder)]">获取技术支持</div>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href="/services"
|
href="/services"
|
||||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||||
<RefreshCw className="w-5 h-5 text-[#C41E3A]" />
|
<RefreshCw className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold text-[#1C1C1C]">服务</div>
|
<div className="font-semibold text-[var(--color-text-primary)]">服务</div>
|
||||||
<div className="text-sm text-[#5C5C5C]">了解我们的服务</div>
|
<div className="text-sm text-[var(--color-text-placeholder)]">了解我们的服务</div>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-sm text-[#5C5C5C]">
|
<div className="mt-8 text-sm text-[var(--color-text-placeholder)]">
|
||||||
如果问题持续存在,请{' '}
|
如果问题持续存在,请{' '}
|
||||||
<StaticLink href="/contact" className="text-[#C41E3A] hover:underline">
|
<StaticLink href="/contact" className="text-[var(--color-brand-primary)] hover:underline">
|
||||||
联系我们的技术团队
|
联系我们的技术团队
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+226
-817
File diff suppressed because it is too large
Load Diff
+33
-23
@@ -1,38 +1,39 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono, Noto_Sans_SC, Ma_Shan_Zheng } from "next/font/google";
|
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import { Ma_Shan_Zheng, Noto_Sans_SC } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { ThemeProvider } from "@/contexts/theme-context";
|
import { ThemeProvider } from "@/contexts/theme-context";
|
||||||
import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
|
import { GoogleAnalyticsWrapper } from "@/components/analytics/GoogleAnalyticsWrapper";
|
||||||
|
import { GlobalErrorTracker } from "@/components/analytics/GlobalErrorTracker";
|
||||||
import { CookieConsent } from "@/components/analytics/CookieConsent";
|
import { CookieConsent } from "@/components/analytics/CookieConsent";
|
||||||
import { PerformanceTracker } from "@/components/analytics/PerformanceTracker";
|
import { PerformanceTracker } from "@/components/analytics/PerformanceTracker";
|
||||||
import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker";
|
import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker";
|
||||||
import { ScrollDepthTracker } from "@/components/analytics/ScrollDepthTracker";
|
import { ScrollDepthTracker } from "@/components/analytics/ScrollDepthTracker";
|
||||||
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
|
import { OrganizationSchema, WebsiteSchema, LocalBusinessSchema } from "@/components/seo/structured-data";
|
||||||
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
||||||
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
||||||
import { ScrollProgress } from "@/components/ui/scroll-progress";
|
import { ScrollProgress } from "@/components/ui/scroll-progress";
|
||||||
import { BackToTop } from "@/components/ui/back-to-top";
|
import { BackToTop } from "@/components/ui/back-to-top";
|
||||||
|
import { ClientLayout } from "@/components/layout/client-layout";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = localFont({
|
||||||
|
src: "./fonts/geist-sans.woff2",
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
preload: false,
|
preload: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = localFont({
|
||||||
|
src: "./fonts/geist-mono.woff2",
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-geist-mono",
|
||||||
subsets: ["latin"],
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
preload: false,
|
preload: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const notoSansSC = Noto_Sans_SC({
|
const aoyagiReisho = localFont({
|
||||||
weight: ["400", "500", "700"],
|
src: "./fonts/AoyagiReisho-subset.ttf",
|
||||||
variable: "--font-noto-sans-sc",
|
variable: "--font-aoyagi-reisho",
|
||||||
subsets: ["latin"],
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
preload: true,
|
preload: true,
|
||||||
});
|
});
|
||||||
@@ -40,17 +41,16 @@ const notoSansSC = Noto_Sans_SC({
|
|||||||
const maShanZheng = Ma_Shan_Zheng({
|
const maShanZheng = Ma_Shan_Zheng({
|
||||||
weight: "400",
|
weight: "400",
|
||||||
variable: "--font-ma-shan-zheng",
|
variable: "--font-ma-shan-zheng",
|
||||||
subsets: ["latin"],
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
preload: true,
|
preload: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 青柳隷書 - 仅用于品牌标题"睿新致遠"(子集版本,仅包含4个字符)
|
const notoSansSC = Noto_Sans_SC({
|
||||||
const aoyagiReisho = localFont({
|
weight: ["400", "500", "700"],
|
||||||
src: "./fonts/AoyagiReisho-subset.ttf",
|
variable: "--font-noto-sans-sc",
|
||||||
variable: "--font-aoyagi-reisho",
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
preload: true,
|
subsets: ["latin"],
|
||||||
|
preload: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -123,6 +123,11 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className="scroll-smooth" suppressHydrationWarning>
|
<html lang="zh-CN" className="scroll-smooth" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem('novalon-theme');if(t==='dark'||(t==='system'||!t)&&window.matchMedia('(prefers-color-scheme:dark)').matches){document.documentElement.setAttribute('data-theme','dark')}}catch(e){}})()`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
@@ -130,27 +135,32 @@ export default function RootLayout({
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="theme-color" content="#C41E3A" />
|
<meta name="theme-color" content="#C41E3A" />
|
||||||
|
<link rel="dns-prefetch" href="//formsubmit.co" />
|
||||||
|
<link rel="preconnect" href="//formsubmit.co" crossOrigin="anonymous" />
|
||||||
<OrganizationSchema />
|
<OrganizationSchema />
|
||||||
<WebsiteSchema />
|
<WebsiteSchema />
|
||||||
|
<LocalBusinessSchema />
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${aoyagiReisho.variable} font-sans antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${aoyagiReisho.variable} ${maShanZheng.variable} ${notoSansSC.variable} font-sans antialiased`}
|
||||||
style={{ fontFamily: "'Noto Sans SC', 'Geist', -apple-system, BlinkMacSystemFont, sans-serif" }}
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-[#C41E3A] focus:text-white focus:rounded-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[#C41E3A]"
|
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-[var(--color-brand-primary)] focus:text-white focus:rounded-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--color-brand-primary)]"
|
||||||
>
|
>
|
||||||
跳转到主内容
|
跳转到主内容
|
||||||
</a>
|
</a>
|
||||||
<ScrollProgress />
|
<ScrollProgress />
|
||||||
<GoogleAnalytics />
|
<GoogleAnalyticsWrapper />
|
||||||
|
<GlobalErrorTracker />
|
||||||
<PerformanceTracker />
|
<PerformanceTracker />
|
||||||
<OutboundLinkTracker />
|
<OutboundLinkTracker />
|
||||||
<ScrollDepthTracker />
|
<ScrollDepthTracker />
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{children}
|
<ClientLayout>
|
||||||
|
{children}
|
||||||
|
</ClientLayout>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
+30
-30
@@ -7,21 +7,21 @@ import { COMPANY_INFO } from '@/lib/constants';
|
|||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
|
||||||
<div className="container-wide px-4 py-20">
|
<div className="container-wide px-4 py-20">
|
||||||
<div className="max-w-2xl mx-auto text-center">
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-[120px] font-bold text-[#C41E3A] leading-none mb-4">
|
<h1 className="text-[120px] font-bold text-[var(--color-brand-primary)] leading-none mb-4">
|
||||||
404
|
404
|
||||||
</h1>
|
</h1>
|
||||||
<div className="w-32 h-1 bg-[#C41E3A] mx-auto mb-6" />
|
<div className="w-32 h-1 bg-[var(--color-brand-primary)] mx-auto mb-6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-4">
|
<h2 className="text-3xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||||
页面未找到
|
页面未找到
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-lg text-[#5C5C5C] mb-8 leading-relaxed">
|
<p className="text-lg text-[var(--color-text-placeholder)] mb-8 leading-relaxed">
|
||||||
很抱歉,您访问的页面不存在或已被移动。
|
很抱歉,您访问的页面不存在或已被移动。
|
||||||
请检查网址是否正确,或使用以下导航继续浏览。
|
请检查网址是否正确,或使用以下导航继续浏览。
|
||||||
</p>
|
</p>
|
||||||
@@ -30,7 +30,7 @@ export default function NotFound() {
|
|||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
asChild
|
asChild
|
||||||
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
|
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white"
|
||||||
>
|
>
|
||||||
<StaticLink href="/">
|
<StaticLink href="/">
|
||||||
<Home className="w-5 h-5 mr-2" />
|
<Home className="w-5 h-5 mr-2" />
|
||||||
@@ -48,69 +48,69 @@ export default function NotFound() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#FAFAFA] rounded-lg p-8">
|
<div className="bg-[var(--color-bg-section)] rounded-lg p-8">
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-6">
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-6">
|
||||||
您可能在寻找
|
您可能在寻找
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href="/about"
|
href="/about"
|
||||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||||
<Search className="w-5 h-5 text-[#C41E3A]" />
|
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold text-[#1C1C1C]">关于我们</div>
|
<div className="font-semibold text-[var(--color-text-primary)]">关于我们</div>
|
||||||
<div className="text-sm text-[#5C5C5C]">了解{COMPANY_INFO.displayName}</div>
|
<div className="text-sm text-[var(--color-text-placeholder)]">了解{COMPANY_INFO.displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href="/services"
|
href="/services"
|
||||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||||
<Search className="w-5 h-5 text-[#C41E3A]" />
|
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold text-[#1C1C1C]">服务</div>
|
<div className="font-semibold text-[var(--color-text-primary)]">服务</div>
|
||||||
<div className="text-sm text-[#5C5C5C]">我们的服务</div>
|
<div className="text-sm text-[var(--color-text-placeholder)]">我们的服务</div>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href="/products"
|
href="/products"
|
||||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||||
<Search className="w-5 h-5 text-[#C41E3A]" />
|
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold text-[#1C1C1C]">产品</div>
|
<div className="font-semibold text-[var(--color-text-primary)]">产品</div>
|
||||||
<div className="text-sm text-[#5C5C5C]">企业级产品</div>
|
<div className="text-sm text-[var(--color-text-placeholder)]">企业级产品</div>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href="/solutions"
|
href="/solutions"
|
||||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
|
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||||
<Search className="w-5 h-5 text-[#C41E3A]" />
|
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-semibold text-[#1C1C1C]">解决方案</div>
|
<div className="font-semibold text-[var(--color-text-primary)]">解决方案</div>
|
||||||
<div className="text-sm text-[#5C5C5C]">行业方案</div>
|
<div className="text-sm text-[var(--color-text-placeholder)]">行业方案</div>
|
||||||
</div>
|
</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-sm text-[#5C5C5C]">
|
<div className="mt-8 text-sm text-[var(--color-text-placeholder)]">
|
||||||
如果您认为这是一个错误,请{' '}
|
如果您认为这是一个错误,请{' '}
|
||||||
<StaticLink href="/contact" className="text-[#C41E3A] hover:underline">
|
<StaticLink href="/contact" className="text-[var(--color-brand-primary)] hover:underline">
|
||||||
联系我们
|
联系我们
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+68
-68
@@ -8,8 +8,8 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function PrivacyPolicyPage() {
|
export default function PrivacyPolicyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<div className="bg-gradient-to-br from-[#C41E3A] to-[#1C1C1C] py-20">
|
<div className="bg-gradient-to-br from-[var(--color-brand-primary)] via-[var(--color-brand-primary)]/80 to-[var(--color-hero-dark-end)] py-20">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||||
隐私政策
|
隐私政策
|
||||||
@@ -24,31 +24,31 @@ export default function PrivacyPolicyPage() {
|
|||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="prose prose-lg max-w-none">
|
<div className="prose prose-lg max-w-none">
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">引言</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">引言</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
四川睿新致远科技有限公司(以下简称“我们”、“公司”)深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。
|
四川睿新致远科技有限公司(以下简称“我们”、“公司”)深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
本隐私政策适用于您通过四川睿新致远科技有限公司官方网站、移动应用、产品服务等渠道访问和使用我们的产品和服务时,我们收集和使用您的个人信息的情形。
|
本隐私政策适用于您通过四川睿新致远科技有限公司官方网站、移动应用、产品服务等渠道访问和使用我们的产品和服务时,我们收集和使用您的个人信息的情形。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">一、我们如何收集和使用您的个人信息</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">一、我们如何收集和使用您的个人信息</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们会遵循正当、合法、必要的原则,仅为实现产品功能,向您提供服务之目的,收集和使用您的个人信息。
|
我们会遵循正当、合法、必要的原则,仅为实现产品功能,向您提供服务之目的,收集和使用您的个人信息。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">1.1 我们收集的个人信息</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">1.1 我们收集的个人信息</h3>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
|
||||||
<li>账户信息:当您注册账户时,我们可能收集您的姓名、电子邮箱地址、手机号码、公司名称等。</li>
|
<li>账户信息:当您注册账户时,我们可能收集您的姓名、电子邮箱地址、手机号码、公司名称等。</li>
|
||||||
<li>联系信息:当您通过联系表单或客服与我们沟通时,我们可能收集您的姓名、电子邮箱地址、手机号码、留言内容等。</li>
|
<li>联系信息:当您通过联系表单或客服与我们沟通时,我们可能收集您的姓名、电子邮箱地址、手机号码、留言内容等。</li>
|
||||||
<li>使用信息:我们可能收集您使用我们产品和服务的信息,包括访问时间、浏览记录、操作日志等。</li>
|
<li>使用信息:我们可能收集您使用我们产品和服务的信息,包括访问时间、浏览记录、操作日志等。</li>
|
||||||
<li>设备信息:我们可能收集您使用的设备信息,包括设备型号、操作系统、浏览器类型等。</li>
|
<li>设备信息:我们可能收集您使用的设备信息,包括设备型号、操作系统、浏览器类型等。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">1.2 我们如何使用您的个人信息</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">1.2 我们如何使用您的个人信息</h3>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>提供产品和服务:向您提供我们的产品和服务,处理您的请求和订单。</li>
|
<li>提供产品和服务:向您提供我们的产品和服务,处理您的请求和订单。</li>
|
||||||
<li>客户服务:与您联系,提供客户支持,回复您的咨询和反馈。</li>
|
<li>客户服务:与您联系,提供客户支持,回复您的咨询和反馈。</li>
|
||||||
<li>改进产品和服务:分析使用情况,改进我们的产品和服务质量。</li>
|
<li>改进产品和服务:分析使用情况,改进我们的产品和服务质量。</li>
|
||||||
@@ -58,33 +58,33 @@ export default function PrivacyPolicyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">二、我们如何共享、转让、公开披露您的个人信息</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">二、我们如何共享、转让、公开披露您的个人信息</h2>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.1 共享</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.1 共享</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们不会向其他任何公司、组织和个人分享您的个人信息,但以下情况除外:
|
我们不会向其他任何公司、组织和个人分享您的个人信息,但以下情况除外:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
|
||||||
<li>在获取明确同意的情况下分享:获得您的明确同意后,我们会与其他方共享您的个人信息。</li>
|
<li>在获取明确同意的情况下分享:获得您的明确同意后,我们会与其他方共享您的个人信息。</li>
|
||||||
<li>根据法律法规、法律程序、强制性的行政或司法要求所必须的情况进行提供。</li>
|
<li>根据法律法规、法律程序、强制性的行政或司法要求所必须的情况进行提供。</li>
|
||||||
<li>在涉及合并、收购或破产清算时,如涉及到个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受本隐私政策的约束。</li>
|
<li>在涉及合并、收购或破产清算时,如涉及到个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受本隐私政策的约束。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.2 转让</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.2 转让</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们不会将您的个人信息转让给任何公司、组织和个人,但以下情况除外:
|
我们不会将您的个人信息转让给任何公司、组织和个人,但以下情况除外:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
|
||||||
<li>在获取明确同意的情况下转让。</li>
|
<li>在获取明确同意的情况下转让。</li>
|
||||||
<li>根据适用的法律法规、法律程序、强制性的行政或司法要求所必须的情况进行提供。</li>
|
<li>根据适用的法律法规、法律程序、强制性的行政或司法要求所必须的情况进行提供。</li>
|
||||||
<li>在涉及合并、收购或破产清算时,如涉及到个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受本隐私政策的约束。</li>
|
<li>在涉及合并、收购或破产清算时,如涉及到个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受本隐私政策的约束。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.3 公开披露</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.3 公开披露</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们仅会在以下情况下,公开披露您的个人信息:
|
我们仅会在以下情况下,公开披露您的个人信息:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>获得您的明确同意。</li>
|
<li>获得您的明确同意。</li>
|
||||||
<li>基于法律法规或法律程序的要求。</li>
|
<li>基于法律法规或法律程序的要求。</li>
|
||||||
<li>在涉及合并、收购或破产清算时。</li>
|
<li>在涉及合并、收购或破产清算时。</li>
|
||||||
@@ -93,11 +93,11 @@ export default function PrivacyPolicyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">三、我们如何保护您的个人信息</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">三、我们如何保护您的个人信息</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们已使用符合业界标准的安全防护措施保护您提供的个人信息,防止数据遭到未经授权访问、公开披露、使用、修改、损坏或丢失。我们会采取一切合理可行的措施,保护您的个人信息。
|
我们已使用符合业界标准的安全防护措施保护您提供的个人信息,防止数据遭到未经授权访问、公开披露、使用、修改、损坏或丢失。我们会采取一切合理可行的措施,保护您的个人信息。
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>使用加密技术确保数据传输和存储安全。</li>
|
<li>使用加密技术确保数据传输和存储安全。</li>
|
||||||
<li>限制访问权限,仅授权人员可访问个人信息。</li>
|
<li>限制访问权限,仅授权人员可访问个人信息。</li>
|
||||||
<li>定期进行安全审计和风险评估。</li>
|
<li>定期进行安全审计和风险评估。</li>
|
||||||
@@ -107,11 +107,11 @@ export default function PrivacyPolicyPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">四、您的权利</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">四、您的权利</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
按照中国相关的法律、法规、标准,以及其他国家、地区的通行做法,我们保障您对自己的个人信息行使以下权利:
|
按照中国相关的法律、法规、标准,以及其他国家、地区的通行做法,我们保障您对自己的个人信息行使以下权利:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>访问您的个人信息。</li>
|
<li>访问您的个人信息。</li>
|
||||||
<li>更正您的个人信息。</li>
|
<li>更正您的个人信息。</li>
|
||||||
<li>删除您的个人信息。</li>
|
<li>删除您的个人信息。</li>
|
||||||
@@ -119,82 +119,82 @@ export default function PrivacyPolicyPage() {
|
|||||||
<li>注销您的账户。</li>
|
<li>注销您的账户。</li>
|
||||||
<li>获取您的个人信息副本。</li>
|
<li>获取您的个人信息副本。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mt-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mt-4">
|
||||||
如您需要行使上述权利,请通过本隐私政策提供的联系方式与我们联系。
|
如您需要行使上述权利,请通过本隐私政策提供的联系方式与我们联系。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">五、未成年人保护</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">五、未成年人保护</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们非常重视对未成年人个人信息的保护。如果您是18周岁以下的未成年人,在使用我们的产品和服务前,应事先取得您家长或法定监护人的同意。如您是未成年人的监护人,当您对您所监护的未成年人的个人信息处理存在疑问时,请通过本隐私政策提供的联系方式与我们联系。
|
我们非常重视对未成年人个人信息的保护。如果您是18周岁以下的未成年人,在使用我们的产品和服务前,应事先取得您家长或法定监护人的同意。如您是未成年人的监护人,当您对您所监护的未成年人的个人信息处理存在疑问时,请通过本隐私政策提供的联系方式与我们联系。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">六、隐私政策的更新</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">六、隐私政策的更新</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们可能适时更新本隐私政策的条款,该等更新构成本隐私政策的一部分。如该等更新造成您在本隐私政策下权利的实质减少,我们将在更新生效前通过在主页上显著位置提示或向您发送电子邮件或其他方式通知您,在该种情况下,若您继续使用我们的服务,即表示同意受经修订的本隐私政策的约束。
|
我们可能适时更新本隐私政策的条款,该等更新构成本隐私政策的一部分。如该等更新造成您在本隐私政策下权利的实质减少,我们将在更新生效前通过在主页上显著位置提示或向您发送电子邮件或其他方式通知您,在该种情况下,若您继续使用我们的服务,即表示同意受经修订的本隐私政策的约束。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">七、Cookie 和网站分析工具</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">七、Cookie 和网站分析工具</h2>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.1 Cookie 使用说明</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.1 Cookie 使用说明</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们使用 Cookie 和类似技术来提供、保护和改进我们的服务。Cookie 是存储在您设备上的小型文本文件,帮助我们识别您的设备、记住您的偏好设置。
|
我们使用 Cookie 和类似技术来提供、保护和改进我们的服务。Cookie 是存储在您设备上的小型文本文件,帮助我们识别您的设备、记住您的偏好设置。
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto mb-6">
|
<div className="overflow-x-auto mb-6">
|
||||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
<table className="min-w-full border border-[var(--color-border)] rounded-lg">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-[var(--color-bg-tertiary)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b">Cookie 类型</th>
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">Cookie 类型</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b">用途</th>
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">用途</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b">持续时间</th>
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">持续时间</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b">是否必需</th>
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">是否必需</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">必要 Cookie</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">必要 Cookie</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">网站基本功能运行</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">网站基本功能运行</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">会话期间</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">会话期间</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">是</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">是</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">分析 Cookie</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">分析 Cookie</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">了解网站使用情况,改进服务</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">了解网站使用情况,改进服务</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">14个月</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">14个月</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">否</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">否</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">营销 Cookie</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">营销 Cookie</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">个性化广告(当前未使用)</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">个性化广告(当前未使用)</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">-</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">-</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#5C5C5C]">否</td>
|
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">否</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.2 Google Analytics 使用说明</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.2 Google Analytics 使用说明</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们使用 Google Analytics 4(由 Google LLC 提供)分析网站使用情况,帮助我们了解访客如何使用网站,从而改进用户体验。
|
我们使用 Google Analytics 4(由 Google LLC 提供)分析网站使用情况,帮助我们了解访客如何使用网站,从而改进用户体验。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-2">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-2">
|
||||||
<strong>收集的数据包括:</strong>
|
<strong>收集的数据包括:</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-1 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-1 mb-4">
|
||||||
<li>访问的页面和停留时间</li>
|
<li>访问的页面和停留时间</li>
|
||||||
<li>设备类型、浏览器类型</li>
|
<li>设备类型、浏览器类型</li>
|
||||||
<li>地理位置(国家/城市级别,IP 地址已匿名化)</li>
|
<li>地理位置(国家/城市级别,IP 地址已匿名化)</li>
|
||||||
<li>访问来源(直接访问、搜索引擎、外部链接)</li>
|
<li>访问来源(直接访问、搜索引擎、外部链接)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-2">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-2">
|
||||||
<strong>我们已采取的保护措施:</strong>
|
<strong>我们已采取的保护措施:</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-1 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-1 mb-4">
|
||||||
<li>IP 地址匿名化</li>
|
<li>IP 地址匿名化</li>
|
||||||
<li>数据保留期限设为 14 个月</li>
|
<li>数据保留期限设为 14 个月</li>
|
||||||
<li>禁用广告个性化功能</li>
|
<li>禁用广告个性化功能</li>
|
||||||
@@ -202,29 +202,29 @@ export default function PrivacyPolicyPage() {
|
|||||||
<li>不与 Google 其他服务共享数据用于广告目的</li>
|
<li>不与 Google 其他服务共享数据用于广告目的</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.3 您的选择</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.3 您的选择</h3>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
|
||||||
<li>您可以在首次访问时选择接受或拒绝分析 Cookie</li>
|
<li>您可以在首次访问时选择接受或拒绝分析 Cookie</li>
|
||||||
<li>您可以点击页面右下角的“Cookie 设置”按钮随时更改偏好</li>
|
<li>您可以点击页面右下角的“Cookie 设置”按钮随时更改偏好</li>
|
||||||
<li>您可以通过浏览器设置删除或阻止 Cookie(可能影响网站功能)</li>
|
<li>您可以通过浏览器设置删除或阻止 Cookie(可能影响网站功能)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.4 数据删除请求</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.4 数据删除请求</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
如您希望删除我们持有的您的个人数据,或撤回您的同意,请通过以下方式联系我们:
|
如您希望删除我们持有的您的个人数据,或撤回您的同意,请通过以下方式联系我们:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-none text-[#5C5C5C] space-y-1 mt-2">
|
<ul className="list-none text-[var(--color-text-muted)] space-y-1 mt-2">
|
||||||
<li>隐私邮箱:privacy@novalon.cn</li>
|
<li>隐私邮箱:privacy@novalon.cn</li>
|
||||||
<li>联系地址:中国四川省成都市龙泉驿区幸福路12号</li>
|
<li>联系地址:中国四川省成都市龙泉驿区幸福路12号</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">八、如何联系我们</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">八、如何联系我们</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
如果您对本隐私政策有任何疑问、意见或建议,或需要行使您的权利,请通过以下方式与我们联系:
|
如果您对本隐私政策有任何疑问、意见或建议,或需要行使您的权利,请通过以下方式与我们联系:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-none text-[#5C5C5C] space-y-2">
|
<ul className="list-none text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>公司名称:四川睿新致远科技有限公司</li>
|
<li>公司名称:四川睿新致远科技有限公司</li>
|
||||||
<li>联系邮箱:contact@novalon.cn</li>
|
<li>联系邮箱:contact@novalon.cn</li>
|
||||||
<li>隐私邮箱:privacy@novalon.cn</li>
|
<li>隐私邮箱:privacy@novalon.cn</li>
|
||||||
|
|||||||
+46
-46
@@ -8,8 +8,8 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function TermsOfServicePage() {
|
export default function TermsOfServicePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<div className="bg-gradient-to-br from-[#C41E3A] to-[#1C1C1C] py-20">
|
<div className="bg-gradient-to-br from-[var(--color-brand-primary)] via-[var(--color-brand-primary)]/80 to-[var(--color-hero-dark-end)] py-20">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||||
服务条款
|
服务条款
|
||||||
@@ -24,48 +24,48 @@ export default function TermsOfServicePage() {
|
|||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="prose prose-lg max-w-none">
|
<div className="prose prose-lg max-w-none">
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">引言</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">引言</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
欢迎使用四川睿新致远科技有限公司(以下简称“我们”、“公司”)提供的产品和服务。在使用我们的产品和服务之前,请您仔细阅读并理解本服务条款。如果您不同意本服务条款的任何内容,请停止使用我们的产品和服务。
|
欢迎使用四川睿新致远科技有限公司(以下简称“我们”、“公司”)提供的产品和服务。在使用我们的产品和服务之前,请您仔细阅读并理解本服务条款。如果您不同意本服务条款的任何内容,请停止使用我们的产品和服务。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
本服务条款是您与四川睿新致远科技有限公司之间就使用我们的产品和服务所订立的协议。我们有权根据需要不时修改本服务条款,修改后的条款一旦公布即代替原条款,恕不另行通知。
|
本服务条款是您与四川睿新致远科技有限公司之间就使用我们的产品和服务所订立的协议。我们有权根据需要不时修改本服务条款,修改后的条款一旦公布即代替原条款,恕不另行通知。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">一、服务内容</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">一、服务内容</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们提供的产品和服务包括但不限于:软件开发、云服务、数据分析、信息安全、企业级软件产品(如ERP、CRM、后台管理系统、BI等)等。具体服务内容以我们官方网站或产品文档为准。
|
我们提供的产品和服务包括但不限于:软件开发、云服务、数据分析、信息安全、企业级软件产品(如ERP、CRM、后台管理系统、BI等)等。具体服务内容以我们官方网站或产品文档为准。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
我们保留随时修改、暂停或终止部分或全部服务的权利,无需事先通知。对于因服务修改、暂停或终止而给您造成的任何损失,我们不承担任何责任,除非法律另有规定。
|
我们保留随时修改、暂停或终止部分或全部服务的权利,无需事先通知。对于因服务修改、暂停或终止而给您造成的任何损失,我们不承担任何责任,除非法律另有规定。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">二、用户注册与账户</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">二、用户注册与账户</h2>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.1 注册资格</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.1 注册资格</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
您确认,在您完成注册程序或以其他方式实际使用本服务时,您应当是具备完全民事权利能力和完全民事行为能力的自然人、法人或其他组织。若您不具备前述主体资格,则您及您的监护人应承担因此而导致的一切后果,且我们有权注销或永久冻结您的账户。
|
您确认,在您完成注册程序或以其他方式实际使用本服务时,您应当是具备完全民事权利能力和完全民事行为能力的自然人、法人或其他组织。若您不具备前述主体资格,则您及您的监护人应承担因此而导致的一切后果,且我们有权注销或永久冻结您的账户。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.2 账户安全</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.2 账户安全</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
您有责任维护您账户的安全性和保密性。您不得向任何第三方泄露您的账户信息,也不得与他人共享您的账户。如果您发现任何未经授权使用您账户的情况,应立即通知我们。
|
您有责任维护您账户的安全性和保密性。您不得向任何第三方泄露您的账户信息,也不得与他人共享您的账户。如果您发现任何未经授权使用您账户的情况,应立即通知我们。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
您对您账户下发生的所有活动负责。我们对因您未能维护账户安全而造成的任何损失不承担责任。
|
您对您账户下发生的所有活动负责。我们对因您未能维护账户安全而造成的任何损失不承担责任。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">三、用户行为规范</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">三、用户行为规范</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
在使用我们的产品和服务时,您同意遵守以下行为规范:
|
在使用我们的产品和服务时,您同意遵守以下行为规范:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
|
||||||
<li>遵守所有适用的法律法规和本服务条款。</li>
|
<li>遵守所有适用的法律法规和本服务条款。</li>
|
||||||
<li>不得利用我们的产品和服务进行任何违法或不当活动。</li>
|
<li>不得利用我们的产品和服务进行任何违法或不当活动。</li>
|
||||||
<li>不得干扰或破坏我们的产品和服务或与我们的产品和服务相连的服务器和网络。</li>
|
<li>不得干扰或破坏我们的产品和服务或与我们的产品和服务相连的服务器和网络。</li>
|
||||||
@@ -74,52 +74,52 @@ export default function TermsOfServicePage() {
|
|||||||
<li>不得进行任何形式的商业欺诈或诈骗活动。</li>
|
<li>不得进行任何形式的商业欺诈或诈骗活动。</li>
|
||||||
<li>不得恶意收集或获取其他用户的信息。</li>
|
<li>不得恶意收集或获取其他用户的信息。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
如果我们认定您违反了本服务条款或任何适用法律,我们有权在不事先通知的情况下,暂停或终止您的账户,并拒绝您现在或将来使用我们的产品和服务。
|
如果我们认定您违反了本服务条款或任何适用法律,我们有权在不事先通知的情况下,暂停或终止您的账户,并拒绝您现在或将来使用我们的产品和服务。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">四、知识产权</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">四、知识产权</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们的产品和服务中包含的所有内容,包括但不限于软件、设计、文字、图片、音频、视频、商标、服务标识等,均受著作权法、商标法、专利法或其他适用法律的保护。
|
我们的产品和服务中包含的所有内容,包括但不限于软件、设计、文字、图片、音频、视频、商标、服务标识等,均受著作权法、商标法、专利法或其他适用法律的保护。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
除非另有明确说明,我们拥有或持有我们产品和服务中所有内容的所有知识产权。您不得以任何形式复制、修改、传播、展示、执行、创作衍生作品、转让、出售或以其他方式使用这些内容,除非获得我们的明确书面许可。
|
除非另有明确说明,我们拥有或持有我们产品和服务中所有内容的所有知识产权。您不得以任何形式复制、修改、传播、展示、执行、创作衍生作品、转让、出售或以其他方式使用这些内容,除非获得我们的明确书面许可。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
您在使用我们的产品和服务时产生的任何内容,您仍保留其知识产权,但您授予我们全球性、非独占性、免版税的许可,以使用、复制、修改、传播、展示和执行这些内容,仅限于提供和改进我们的产品和服务。
|
您在使用我们的产品和服务时产生的任何内容,您仍保留其知识产权,但您授予我们全球性、非独占性、免版税的许可,以使用、复制、修改、传播、展示和执行这些内容,仅限于提供和改进我们的产品和服务。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">五、服务费用与支付</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">五、服务费用与支付</h2>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">5.1 费用标准</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">5.1 费用标准</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们的产品和服务可能需要支付费用。具体费用标准以我们官方网站或产品文档公布的价格为准。我们保留随时调整价格的权利,调整后的价格适用于调整后的新订单或续费。
|
我们的产品和服务可能需要支付费用。具体费用标准以我们官方网站或产品文档公布的价格为准。我们保留随时调整价格的权利,调整后的价格适用于调整后的新订单或续费。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">5.2 支付方式</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">5.2 支付方式</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们接受多种支付方式,包括但不限于银行转账、支付宝、微信支付等。具体支付方式以我们官方网站或产品文档为准。
|
我们接受多种支付方式,包括但不限于银行转账、支付宝、微信支付等。具体支付方式以我们官方网站或产品文档为准。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">5.3 退款政策</h3>
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">5.3 退款政策</h3>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed">
|
<p className="text-[var(--color-text-muted)] leading-relaxed">
|
||||||
除非另有明确说明,我们提供的产品和服务一经售出,不予退款。如因我们的原因导致产品或服务无法正常使用,我们将根据实际情况提供相应的补偿或解决方案。
|
除非另有明确说明,我们提供的产品和服务一经售出,不予退款。如因我们的原因导致产品或服务无法正常使用,我们将根据实际情况提供相应的补偿或解决方案。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">六、免责声明</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">六、免责声明</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们的产品和服务按“现状”和“可用”基础提供,不提供任何明示或暗示的保证,包括但不限于对适销性、适用性、非侵权性或准确性、可靠性的保证。
|
我们的产品和服务按“现状”和“可用”基础提供,不提供任何明示或暗示的保证,包括但不限于对适销性、适用性、非侵权性或准确性、可靠性的保证。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
我们不对以下情况承担责任:
|
我们不对以下情况承担责任:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>因您违反本服务条款或任何适用法律而导致的任何损失或损害。</li>
|
<li>因您违反本服务条款或任何适用法律而导致的任何损失或损害。</li>
|
||||||
<li>因不可抗力、网络故障、设备故障等不可控因素导致的服务中断或数据丢失。</li>
|
<li>因不可抗力、网络故障、设备故障等不可控因素导致的服务中断或数据丢失。</li>
|
||||||
<li>因第三方服务或内容导致的任何损失或损害。</li>
|
<li>因第三方服务或内容导致的任何损失或损害。</li>
|
||||||
@@ -128,11 +128,11 @@ export default function TermsOfServicePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">七、服务终止</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">七、服务终止</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
您可以随时停止使用我们的产品和服务,并注销您的账户。我们也有权在不事先通知的情况下,因以下原因暂停或终止您的账户:
|
您可以随时停止使用我们的产品和服务,并注销您的账户。我们也有权在不事先通知的情况下,因以下原因暂停或终止您的账户:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>您违反本服务条款或任何适用法律。</li>
|
<li>您违反本服务条款或任何适用法律。</li>
|
||||||
<li>我们出于安全、法律或商业考虑,认为有必要终止您的账户。</li>
|
<li>我们出于安全、法律或商业考虑,认为有必要终止您的账户。</li>
|
||||||
<li>您长时间未使用您的账户。</li>
|
<li>您长时间未使用您的账户。</li>
|
||||||
@@ -141,15 +141,15 @@ export default function TermsOfServicePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">八、争议解决</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">八、争议解决</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
本服务条款的订立、执行、解释及争议解决均适用中华人民共和国法律。如就本服务条款发生任何争议,双方应首先通过友好协商解决;协商不成的,任何一方均可向公司所在地有管辖权的人民法院提起诉讼。
|
本服务条款的订立、执行、解释及争议解决均适用中华人民共和国法律。如就本服务条款发生任何争议,双方应首先通过友好协商解决;协商不成的,任何一方均可向公司所在地有管辖权的人民法院提起诉讼。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">九、其他条款</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">九、其他条款</h2>
|
||||||
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
|
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>本服务条款构成您与我们就使用我们的产品和服务所达成的完整协议,取代之前的所有口头或书面协议。</li>
|
<li>本服务条款构成您与我们就使用我们的产品和服务所达成的完整协议,取代之前的所有口头或书面协议。</li>
|
||||||
<li>如本服务条款的任何条款被认定为无效或不可执行,该条款应被限制或排除,以使其有效和可执行,其余条款继续有效。</li>
|
<li>如本服务条款的任何条款被认定为无效或不可执行,该条款应被限制或排除,以使其有效和可执行,其余条款继续有效。</li>
|
||||||
<li>我们未行使或延迟行使本服务条款项下的任何权利或规定,不构成对该权利或规定的放弃。</li>
|
<li>我们未行使或延迟行使本服务条款项下的任何权利或规定,不构成对该权利或规定的放弃。</li>
|
||||||
@@ -158,20 +158,20 @@ export default function TermsOfServicePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">十、联系我们</h2>
|
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">十、联系我们</h2>
|
||||||
<p className="text-[#5C5C5C] leading-relaxed mb-4">
|
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||||
如果您对本服务条款有任何疑问、意见或建议,请通过以下方式与我们联系:
|
如果您对本服务条款有任何疑问、意见或建议,请通过以下方式与我们联系:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-none text-[#5C5C5C] space-y-2">
|
<ul className="list-none text-[var(--color-text-muted)] space-y-2">
|
||||||
<li>公司名称:四川睿新致远科技有限公司</li>
|
<li>公司名称:四川睿新致远科技有限公司</li>
|
||||||
<li>联系邮箱:contact@novalon.cn</li>
|
<li>联系邮箱:contact@novalon.cn</li>
|
||||||
<li>联系地址:中国四川省成都市龙泉驿区幸福路12号</li>
|
<li>联系地址:中国四川省成都市龙泉驿区幸福路12号</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-[#FFFBF5] p-6 rounded-lg border-l-4 border-[#C41E3A]">
|
<section className="bg-[var(--color-warning-bg)] p-6 rounded-lg border-l-4 border-[var(--color-brand-primary)]">
|
||||||
<p className="text-[#1C1C1C] font-medium mb-2">最后更新日期</p>
|
<p className="text-[var(--color-text-primary)] font-medium mb-2">最后更新日期</p>
|
||||||
<p className="text-[#5C5C5C]">2026年2月26日</p>
|
<p className="text-[var(--color-text-muted)]">2026年2月26日</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { trackError } from '@/lib/analytics';
|
||||||
|
|
||||||
|
export default function TestErrorTrackingPage() {
|
||||||
|
const [testResults, setTestResults] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const addResult = (message: string) => {
|
||||||
|
setTestResults((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testJavaScriptError = () => {
|
||||||
|
try {
|
||||||
|
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
||||||
|
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
||||||
|
} catch (error) {
|
||||||
|
trackError('javascript_error', error.message, false, {
|
||||||
|
test_id: 'test_js_error',
|
||||||
|
filename: 'test-error-page.tsx',
|
||||||
|
lineno: 20,
|
||||||
|
});
|
||||||
|
addResult('✅ JavaScript 错误已发送到 GA4');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testPromiseRejection = () => {
|
||||||
|
addResult('🧪 测试 2: 触发 Promise 未捕获异常...');
|
||||||
|
Promise.reject(new Error('测试错误:Promise 故意拒绝'));
|
||||||
|
addResult('✅ Promise 异常已触发(由 GlobalErrorTracker 自动捕获)');
|
||||||
|
};
|
||||||
|
|
||||||
|
const testReactError = () => {
|
||||||
|
addResult('🧪 测试 3: 触发 React 渲染错误...');
|
||||||
|
trackError('react_error', '组件渲染失败:测试故意错误', true, {
|
||||||
|
component: 'TestComponent',
|
||||||
|
stack_trace: 'Error: 测试错误\n at TestComponent...',
|
||||||
|
});
|
||||||
|
addResult('✅ React 错误已发送到 GA4(标记为 fatal)');
|
||||||
|
};
|
||||||
|
|
||||||
|
const testNetworkError = () => {
|
||||||
|
addResult('🧪 测试 4: 模拟网络请求错误...');
|
||||||
|
trackError('network_error', 'Failed to fetch: https://api.example.com/data', false, {
|
||||||
|
url: 'https://api.example.com/data',
|
||||||
|
status_code: 0,
|
||||||
|
request_method: 'GET',
|
||||||
|
});
|
||||||
|
addResult('✅ 网络错误已发送到 GA4');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearResults = () => {
|
||||||
|
setTestResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||||
|
🧪 GA4 错误监控测试面板
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
测试用例
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={testJavaScriptError}
|
||||||
|
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
📛 测试 JavaScript 错误
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={testPromiseRejection}
|
||||||
|
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||||
|
>
|
||||||
|
⚠️ 测试 Promise 异常
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={testReactError}
|
||||||
|
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
💥 测试 React 致命错误
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={testNetworkError}
|
||||||
|
className="px-6 py-3 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
|
||||||
|
>
|
||||||
|
🌐 测试网络错误
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearResults}
|
||||||
|
className="mt-4 px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
🗑️ 清除日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResults.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-green-400 mb-4">
|
||||||
|
📋 测试日志
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 font-mono text-sm">
|
||||||
|
{testResults.map((result, index) => (
|
||||||
|
<div key={index} className="text-green-300">
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||||
|
💡 如何验证错误是否成功发送到 GA4?
|
||||||
|
</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-yellow-700">
|
||||||
|
<li>点击上面的测试按钮</li>
|
||||||
|
<li>
|
||||||
|
打开{' '}
|
||||||
|
<a
|
||||||
|
href="https://analytics.google.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline font-semibold"
|
||||||
|
>
|
||||||
|
Google Analytics 后台
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>导航到:报告 → 实时</li>
|
||||||
|
<li>在事件搜索框输入 <code className="bg-yellow-100 px-1">exception</code></li>
|
||||||
|
<li>你应该能在 30 秒内看到错误事件出现!</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||||
|
🔧 开发者工具调试
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-700 mb-2">
|
||||||
|
在开发模式下,打开浏览器控制台(F12),你会看到类似这样的日志:
|
||||||
|
</p>
|
||||||
|
<pre className="bg-blue-900 text-green-300 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
|
{`[GA4] Error tracked: {
|
||||||
|
description: "[javascript_error] 测试错误:这是一个故意的 JavaScript 错误",
|
||||||
|
fatal: "false",
|
||||||
|
url: "http://localhost:3000/test-error-tracking",
|
||||||
|
timestamp: "2025-01-15T10:30:00.000Z",
|
||||||
|
test_id: "test_js_error",
|
||||||
|
filename: "test-error-page.tsx",
|
||||||
|
lineno: 20
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export function CookieConsent() {
|
|||||||
necessary: true,
|
necessary: true,
|
||||||
analytics: legacyConsent === 'granted',
|
analytics: legacyConsent === 'granted',
|
||||||
marketing: false,
|
marketing: false,
|
||||||
|
functionality: true,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
storePreferences(migratedPrefs);
|
storePreferences(migratedPrefs);
|
||||||
@@ -91,6 +92,7 @@ export function CookieConsent() {
|
|||||||
necessary: true,
|
necessary: true,
|
||||||
analytics: true,
|
analytics: true,
|
||||||
marketing: false,
|
marketing: false,
|
||||||
|
functionality: true,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
handleSavePreferences(allAccepted);
|
handleSavePreferences(allAccepted);
|
||||||
@@ -102,6 +104,7 @@ export function CookieConsent() {
|
|||||||
necessary: true,
|
necessary: true,
|
||||||
analytics: false,
|
analytics: false,
|
||||||
marketing: false,
|
marketing: false,
|
||||||
|
functionality: true,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
handleSavePreferences(allRejected);
|
handleSavePreferences(allRejected);
|
||||||
@@ -124,18 +127,18 @@ export function CookieConsent() {
|
|||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: 100, opacity: 0 }}
|
exit={{ y: 100, opacity: 0 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-white border-t border-gray-200 shadow-lg"
|
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-[var(--color-bg-primary)] border-t border-[var(--color-border-secondary)] shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||||
{!showSettings ? (
|
{!showSettings ? (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||||
我们使用 Cookie 和类似技术来改善您的体验、分析网站流量。
|
我们使用 Cookie 和类似技术来改善您的体验、分析网站流量。
|
||||||
继续使用即表示您同意我们的{' '}
|
继续使用即表示您同意我们的{' '}
|
||||||
<a
|
<a
|
||||||
href="/privacy"
|
href="/privacy"
|
||||||
className="text-[#C41E3A] hover:text-[#A01830] underline font-medium"
|
className="text-[var(--color-brand-primary)] hover:text-[var(--color-brand-primary-hover)] underline font-medium"
|
||||||
>
|
>
|
||||||
隐私政策
|
隐私政策
|
||||||
</a>
|
</a>
|
||||||
@@ -146,21 +149,21 @@ export function CookieConsent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowSettings(true)}
|
||||||
disabled={isAnimating}
|
disabled={isAnimating}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
管理偏好
|
管理偏好
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRejectAll}
|
onClick={handleRejectAll}
|
||||||
disabled={isAnimating}
|
disabled={isAnimating}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
仅必要
|
仅必要
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAcceptAll}
|
onClick={handleAcceptAll}
|
||||||
disabled={isAnimating}
|
disabled={isAnimating}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
接受所有
|
接受所有
|
||||||
</button>
|
</button>
|
||||||
@@ -169,10 +172,10 @@ export function CookieConsent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C]">Cookie 偏好设置</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">Cookie 偏好设置</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(false)}
|
onClick={() => setShowSettings(false)}
|
||||||
className="text-gray-500 hover:text-gray-700"
|
className="text-[var(--color-text-placeholder)] hover:text-[var(--color-text-primary)]"
|
||||||
aria-label="关闭设置"
|
aria-label="关闭设置"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -182,52 +185,52 @@ export function CookieConsent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked
|
checked
|
||||||
disabled
|
disabled
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-not-allowed"
|
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-not-allowed"
|
||||||
aria-label="必要 Cookie"
|
aria-label="必要 Cookie"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-[#1C1C1C]">必要 Cookie</span>
|
<span className="font-medium text-[var(--color-text-primary)]">必要 Cookie</span>
|
||||||
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded">始终启用</span>
|
<span className="text-xs px-2 py-0.5 bg-[var(--color-border-secondary)] text-[var(--color-text-muted)] rounded">始终启用</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#5C5C5C] mt-1">
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
网站正常运行所必需,无法禁用
|
网站正常运行所必需,无法禁用
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={preferences.analytics}
|
checked={preferences.analytics}
|
||||||
onChange={() => handleTogglePreference('analytics')}
|
onChange={() => handleTogglePreference('analytics')}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
|
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
|
||||||
aria-label="分析 Cookie"
|
aria-label="分析 Cookie"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-medium text-[#1C1C1C]">分析 Cookie</span>
|
<span className="font-medium text-[var(--color-text-primary)]">分析 Cookie</span>
|
||||||
<p className="text-sm text-[#5C5C5C] mt-1">
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
帮助我们了解访客如何使用网站,改进用户体验
|
帮助我们了解访客如何使用网站,改进用户体验
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg opacity-50">
|
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg opacity-50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={preferences.marketing}
|
checked={preferences.marketing}
|
||||||
onChange={() => handleTogglePreference('marketing')}
|
onChange={() => handleTogglePreference('marketing')}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
|
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
|
||||||
aria-label="营销 Cookie"
|
aria-label="营销 Cookie"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-medium text-[#1C1C1C]">营销 Cookie</span>
|
<span className="font-medium text-[var(--color-text-primary)]">营销 Cookie</span>
|
||||||
<p className="text-sm text-[#5C5C5C] mt-1">
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
用于个性化广告(当前未使用)
|
用于个性化广告(当前未使用)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,14 +241,14 @@ export function CookieConsent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(false)}
|
onClick={() => setShowSettings(false)}
|
||||||
disabled={isAnimating}
|
disabled={isAnimating}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveCustom}
|
onClick={handleSaveCustom}
|
||||||
disabled={isAnimating}
|
disabled={isAnimating}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
保存偏好
|
保存偏好
|
||||||
</button>
|
</button>
|
||||||
@@ -273,7 +276,7 @@ export function CookieSettingsButton() {
|
|||||||
const event = new CustomEvent('open-cookie-settings');
|
const event = new CustomEvent('open-cookie-settings');
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
}}
|
}}
|
||||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-50 transition-colors"
|
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-primary-lighter)] transition-colors"
|
||||||
aria-label="Cookie 设置"
|
aria-label="Cookie 设置"
|
||||||
>
|
>
|
||||||
Cookie 设置
|
Cookie 设置
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { trackError } from '@/lib/analytics';
|
||||||
|
|
||||||
|
const IGNORED_ERRORS = [
|
||||||
|
/_AutofillCallbackHandler/,
|
||||||
|
/ResizeObserver loop limit exceeded/,
|
||||||
|
/webkit\.messageHandlers/,
|
||||||
|
/Non-Error promise rejection captured/,
|
||||||
|
/^Loading CSS chunk.*failed$/,
|
||||||
|
/^Loading chunk.*failed$/,
|
||||||
|
/^Failed to fetch dynamically imported module/,
|
||||||
|
/Script error/,
|
||||||
|
/NetworkError/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function shouldIgnoreError(message: string): boolean {
|
||||||
|
return IGNORED_ERRORS.some((pattern) => pattern.test(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalErrorTracker() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const handleGlobalError = (
|
||||||
|
event: ErrorEvent
|
||||||
|
) => {
|
||||||
|
if (shouldIgnoreError(event.message)) return;
|
||||||
|
|
||||||
|
trackError(
|
||||||
|
'javascript_error',
|
||||||
|
event.message,
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
filename: event.filename || '',
|
||||||
|
lineno: event.lineno || 0,
|
||||||
|
colno: event.colno || 0,
|
||||||
|
stack: event.error?.stack?.slice(0, 500) || '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
const message =
|
||||||
|
event.reason?.message || String(event.reason) || 'Unknown promise rejection';
|
||||||
|
|
||||||
|
if (shouldIgnoreError(message)) return;
|
||||||
|
|
||||||
|
trackError(
|
||||||
|
'unhandled_promise_rejection',
|
||||||
|
message,
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
reason_type: event.reason?.constructor?.name || 'Unknown',
|
||||||
|
stack: event.reason?.stack?.slice(0, 500) || '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResourceError = () => {
|
||||||
|
trackError('resource_loading_error', 'Failed to load resource', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', handleGlobalError, true);
|
||||||
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
document.addEventListener('error', handleResourceError, true);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[GA4] Global error tracker initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('error', handleGlobalError, true);
|
||||||
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
document.removeEventListener('error', handleResourceError, true);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,16 +2,28 @@
|
|||||||
|
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, Suspense } from 'react';
|
import { useEffect, Suspense, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||||
|
|
||||||
|
function useIsMounted() {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(callback) => {
|
||||||
|
window.addEventListener('resize', callback);
|
||||||
|
return () => window.removeEventListener('resize', callback);
|
||||||
|
},
|
||||||
|
() => true,
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GoogleAnalyticsContent() {
|
function GoogleAnalyticsContent() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const mounted = useIsMounted();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
|
if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;}
|
||||||
|
|
||||||
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
|
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
|
||||||
|
|
||||||
@@ -22,9 +34,9 @@ function GoogleAnalyticsContent() {
|
|||||||
page_location: window.location.origin + url,
|
page_location: window.location.origin + url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams, mounted]);
|
||||||
|
|
||||||
if (!GA_MEASUREMENT_ID) {return null;}
|
if (!GA_MEASUREMENT_ID || !mounted) {return null;}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -61,7 +73,9 @@ function GoogleAnalyticsContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GoogleAnalytics() {
|
export function GoogleAnalytics() {
|
||||||
if (!GA_MEASUREMENT_ID) {return null;}
|
const mounted = useIsMounted();
|
||||||
|
|
||||||
|
if (!GA_MEASUREMENT_ID || !mounted) {return null;}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const GoogleAnalytics = dynamic(
|
||||||
|
() => import('./GoogleAnalytics').then((mod) => mod.GoogleAnalytics),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function GoogleAnalyticsWrapper() {
|
||||||
|
return <GoogleAnalytics />;
|
||||||
|
}
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
|
||||||
import { useMemo, useState, useEffect, useRef } from 'react';
|
|
||||||
import { Cpu, Shield, Zap, Globe, FileText, TrendingUp, BarChart3, Users } from 'lucide-react';
|
|
||||||
|
|
||||||
interface FloatingOrbProps {
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
delay?: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
duration?: number;
|
|
||||||
icon?: any;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FloatingOrb({
|
|
||||||
size = 80,
|
|
||||||
color = 'rgba(196, 30, 58, 0.08)',
|
|
||||||
delay = 0,
|
|
||||||
x = 0,
|
|
||||||
y = 0,
|
|
||||||
duration = 8,
|
|
||||||
icon: Icon,
|
|
||||||
className = ''
|
|
||||||
}: FloatingOrbProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute rounded-full pointer-events-none ${className}`}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
backgroundColor: color,
|
|
||||||
backdropFilter: 'blur(20px)',
|
|
||||||
boxShadow: '0 0 40px rgba(196, 30, 58, 0.1)',
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0, x, y }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 1, 1],
|
|
||||||
scale: [0.5, 1, 1],
|
|
||||||
y: [y, y - 30, y],
|
|
||||||
x: [x, x + 15, x],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: duration,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.5, 1],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Icon && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Icon className="w-5 h-5 text-[#C41E3A]/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FloatingLineProps {
|
|
||||||
startX?: number;
|
|
||||||
startY?: number;
|
|
||||||
endX?: number;
|
|
||||||
endY?: number;
|
|
||||||
color?: string;
|
|
||||||
delay?: number;
|
|
||||||
duration?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FloatingLine({
|
|
||||||
startX = 0,
|
|
||||||
startY = 0,
|
|
||||||
endX = 200,
|
|
||||||
endY = 0,
|
|
||||||
color = 'rgba(28, 28, 28, 0.1)',
|
|
||||||
delay = 0,
|
|
||||||
duration = 6,
|
|
||||||
className = ''
|
|
||||||
}: FloatingLineProps) {
|
|
||||||
return (
|
|
||||||
<motion.svg
|
|
||||||
className={`absolute pointer-events-none ${className}`}
|
|
||||||
style={{
|
|
||||||
left: startX,
|
|
||||||
top: startY,
|
|
||||||
width: Math.abs(endX - startX) || 100,
|
|
||||||
height: Math.abs(endY - startY) || 2,
|
|
||||||
overflow: 'visible',
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: [0, 1, 0.5, 1] }}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.path
|
|
||||||
d={`M0 0 Q${(endX - startX) / 2} ${-20 + Math.random() * 40} ${endX - startX} 0`}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeLinecap="round"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: [0, 1, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: duration * 2,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FloatingIconProps {
|
|
||||||
icon?: any;
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
delay?: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
rotation?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FloatingIcon({
|
|
||||||
icon: Icon,
|
|
||||||
size = 24,
|
|
||||||
color = '#1C1C1C',
|
|
||||||
delay = 0,
|
|
||||||
x = 0,
|
|
||||||
y = 0,
|
|
||||||
rotation = 0,
|
|
||||||
className = ''
|
|
||||||
}: FloatingIconProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute pointer-events-none ${className}`}
|
|
||||||
style={{
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0, rotate: rotation - 15, x, y }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 1, 0.8],
|
|
||||||
scale: [0.8, 1, 0.9],
|
|
||||||
rotate: [rotation - 10, rotation + 10, rotation],
|
|
||||||
y: [y, y - 25, y - 10],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 7 + Math.random() * 3,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.5, 1],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center rounded-full"
|
|
||||||
style={{
|
|
||||||
width: size + 24,
|
|
||||||
height: size + 24,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
border: '1px solid rgba(28, 28, 28, 0.08)',
|
|
||||||
boxShadow: '0 4px 20px rgba(28, 28, 28, 0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" style={{ color }} />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParticleRingProps {
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
delay?: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParticleRing({
|
|
||||||
size = 120,
|
|
||||||
color = 'rgba(196, 30, 58, 0.1)',
|
|
||||||
delay = 0,
|
|
||||||
x = 0,
|
|
||||||
y = 0,
|
|
||||||
className = ''
|
|
||||||
}: ParticleRingProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute pointer-events-none ${className}`}
|
|
||||||
style={{
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 1, 0.5],
|
|
||||||
scale: [0.5, 1.2, 0.8],
|
|
||||||
rotate: [0, 90, 180],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 12,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'linear',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width={size} height={size} viewBox="0 0 120 120">
|
|
||||||
{[0, 60, 120, 180, 240, 300].map((angle, i) => {
|
|
||||||
const rad = (angle * Math.PI) / 180;
|
|
||||||
const px = 60 + Math.cos(rad) * 45;
|
|
||||||
const py = 60 + Math.sin(rad) * 45;
|
|
||||||
return (
|
|
||||||
<motion.circle
|
|
||||||
key={i}
|
|
||||||
cx={px}
|
|
||||||
cy={py}
|
|
||||||
r={3}
|
|
||||||
fill={color}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0.3, 1, 0.3],
|
|
||||||
scale: [0.5, 1.5, 0.5],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 4,
|
|
||||||
delay: delay + i * 0.3,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<circle
|
|
||||||
cx={60}
|
|
||||||
cy={60}
|
|
||||||
r={50}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlowingDotProps {
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
delay?: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GlowingDot({
|
|
||||||
size = 8,
|
|
||||||
color = '#C41E3A',
|
|
||||||
delay = 0,
|
|
||||||
x = 0,
|
|
||||||
y = 0,
|
|
||||||
className = ''
|
|
||||||
}: GlowingDotProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute rounded-full pointer-events-none ${className}`}
|
|
||||||
style={{
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
backgroundColor: color,
|
|
||||||
boxShadow: `0 0 ${size * 2}px ${color}`,
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 1, 0.5, 1],
|
|
||||||
scale: [0.5, 1.5, 0.8, 1.2],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 3 + Math.random() * 2,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdvancedFloatingEffectsProps {
|
|
||||||
variant?: 'minimal' | 'balanced' | 'rich' | 'parallax';
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdvancedFloatingEffects({
|
|
||||||
variant = 'balanced',
|
|
||||||
className = ''
|
|
||||||
}: AdvancedFloatingEffectsProps) {
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { scrollY } = useScroll();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
minimal: { orbs: 2, icons: 3, rings: 0, lines: 2, dots: 5 },
|
|
||||||
balanced: { orbs: 3, icons: 5, rings: 1, lines: 4, dots: 8 },
|
|
||||||
rich: { orbs: 5, icons: 8, rings: 2, lines: 6, dots: 12 },
|
|
||||||
parallax: { orbs: 4, icons: 6, rings: 2, lines: 5, dots: 10 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { orbs, icons, rings, lines, dots } = config[variant];
|
|
||||||
|
|
||||||
const iconsList = [Cpu, Shield, Zap, Globe, FileText, TrendingUp, BarChart3, Users];
|
|
||||||
|
|
||||||
const elements = useMemo(() => {
|
|
||||||
if (!isMounted) {return [];}
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
const width = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
|
||||||
const height = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
|
||||||
|
|
||||||
for (let i = 0; i < orbs; i++) {
|
|
||||||
items.push({
|
|
||||||
type: 'orb',
|
|
||||||
id: `orb-${i}`,
|
|
||||||
props: {
|
|
||||||
size: 60 + Math.random() * 60,
|
|
||||||
color: i % 2 === 0 ? 'rgba(196, 30, 58, 0.08)' : 'rgba(28, 28, 28, 0.05)',
|
|
||||||
delay: i * 0.5,
|
|
||||||
x: width * 0.1 + (i * width * 0.35),
|
|
||||||
y: height * 0.15 + Math.random() * height * 0.5,
|
|
||||||
duration: 7 + Math.random() * 4,
|
|
||||||
icon: i % 3 === 0 ? iconsList[i % iconsList.length] : undefined,
|
|
||||||
},
|
|
||||||
parallaxDepth: 0.1 + i * 0.1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < icons; i++) {
|
|
||||||
items.push({
|
|
||||||
type: 'icon',
|
|
||||||
id: `icon-${i}`,
|
|
||||||
props: {
|
|
||||||
icon: iconsList[i % iconsList.length],
|
|
||||||
size: 20,
|
|
||||||
color: i % 2 === 0 ? '#C41E3A' : '#1C1C1C',
|
|
||||||
delay: i * 0.4,
|
|
||||||
x: width * 0.08 + (i * width * 0.12),
|
|
||||||
y: height * 0.1 + Math.random() * height * 0.65,
|
|
||||||
rotation: -15 + Math.random() * 30,
|
|
||||||
},
|
|
||||||
parallaxDepth: 0.2 + i * 0.05,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < rings; i++) {
|
|
||||||
items.push({
|
|
||||||
type: 'ring',
|
|
||||||
id: `ring-${i}`,
|
|
||||||
props: {
|
|
||||||
size: 100 + Math.random() * 80,
|
|
||||||
color: i % 2 === 0 ? 'rgba(196, 30, 58, 0.1)' : 'rgba(28, 28, 28, 0.08)',
|
|
||||||
delay: i * 0.8,
|
|
||||||
x: width * 0.2 + (i * width * 0.4),
|
|
||||||
y: height * 0.2 + Math.random() * height * 0.4,
|
|
||||||
},
|
|
||||||
parallaxDepth: 0.05 + i * 0.1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < lines; i++) {
|
|
||||||
items.push({
|
|
||||||
type: 'line',
|
|
||||||
id: `line-${i}`,
|
|
||||||
props: {
|
|
||||||
startX: width * 0.05 + (i * width * 0.15),
|
|
||||||
startY: height * 0.1 + Math.random() * height * 0.7,
|
|
||||||
endX: width * 0.05 + (i * width * 0.15) + 80 + Math.random() * 120,
|
|
||||||
endY: height * 0.1 + Math.random() * height * 0.7,
|
|
||||||
color: i % 2 === 0 ? 'rgba(196, 30, 58, 0.15)' : 'rgba(28, 28, 28, 0.1)',
|
|
||||||
delay: i * 0.6,
|
|
||||||
duration: 5 + Math.random() * 3,
|
|
||||||
},
|
|
||||||
parallaxDepth: 0.15 + i * 0.05,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < dots; i++) {
|
|
||||||
items.push({
|
|
||||||
type: 'dot',
|
|
||||||
id: `dot-${i}`,
|
|
||||||
props: {
|
|
||||||
size: 4 + Math.random() * 6,
|
|
||||||
color: i % 3 === 0 ? '#C41E3A' : i % 3 === 1 ? '#1C1C1C' : '#D4A574',
|
|
||||||
delay: i * 0.3,
|
|
||||||
x: Math.random() * width,
|
|
||||||
y: Math.random() * height,
|
|
||||||
},
|
|
||||||
parallaxDepth: 0.25 + i * 0.02,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}, [orbs, icons, rings, lines, dots, isMounted, iconsList]);
|
|
||||||
|
|
||||||
const getParallaxStyle = (depth: number) => {
|
|
||||||
if (variant !== 'parallax') {return {};}
|
|
||||||
const y = useTransform(scrollY, [0, 500], [0, -depth * 100]);
|
|
||||||
return { y };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}
|
|
||||||
>
|
|
||||||
{elements.map((el) => {
|
|
||||||
const parallaxStyle = getParallaxStyle(el.parallaxDepth);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div key={el.id} style={parallaxStyle}>
|
|
||||||
{el.type === 'orb' && <FloatingOrb {...el.props} />}
|
|
||||||
{el.type === 'icon' && <FloatingIcon {...el.props} />}
|
|
||||||
{el.type === 'ring' && <ParticleRing {...el.props} />}
|
|
||||||
{el.type === 'line' && <FloatingLine {...el.props} />}
|
|
||||||
{el.type === 'dot' && <GlowingDot {...el.props} />}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdvancedFloatingEffects;
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface DataParticleFlowProps {
|
|
||||||
className?: string;
|
|
||||||
particleCount?: number;
|
|
||||||
color?: string;
|
|
||||||
intensity?: 'subtle' | 'normal' | 'prominent';
|
|
||||||
shape?: 'circle' | 'square' | 'triangle' | 'diamond' | 'star' | 'mixed';
|
|
||||||
effect?: 'default' | 'pulse' | 'glow' | 'trail';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
id: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
size: number;
|
|
||||||
duration: number;
|
|
||||||
delay: number;
|
|
||||||
opacity: number;
|
|
||||||
moveRange: number;
|
|
||||||
shape: 'circle' | 'square' | 'triangle' | 'diamond' | 'star';
|
|
||||||
rotation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataParticleFlow({
|
|
||||||
className = '',
|
|
||||||
particleCount = 50,
|
|
||||||
color = '#C41E3A',
|
|
||||||
intensity = 'normal',
|
|
||||||
shape = 'circle',
|
|
||||||
effect = 'default',
|
|
||||||
}: DataParticleFlowProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const [particles, setParticles] = useState<Particle[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const intensityConfig = {
|
|
||||||
subtle: { sizeMin: 3, sizeMax: 8, opacityMin: 0.2, opacityMax: 0.4, moveRange: 80 },
|
|
||||||
normal: { sizeMin: 6, sizeMax: 16, opacityMin: 0.4, opacityMax: 0.7, moveRange: 150 },
|
|
||||||
prominent: { sizeMin: 10, sizeMax: 24, opacityMin: 0.5, opacityMax: 0.9, moveRange: 200 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const shapes: Particle['shape'][] = ['circle', 'square', 'triangle', 'diamond', 'star'];
|
|
||||||
const config = intensityConfig[intensity];
|
|
||||||
|
|
||||||
const generated: Particle[] = Array.from({ length: particleCount }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
size: Math.random() * (config.sizeMax - config.sizeMin) + config.sizeMin,
|
|
||||||
duration: Math.random() * 12 + 8,
|
|
||||||
delay: Math.random() * 3,
|
|
||||||
opacity: Math.random() * (config.opacityMax - config.opacityMin) + config.opacityMin,
|
|
||||||
moveRange: config.moveRange,
|
|
||||||
shape: shape === 'mixed' ? (shapes[Math.floor(Math.random() * shapes.length)] ?? 'circle') : shape as Particle['shape'],
|
|
||||||
rotation: Math.random() * 360,
|
|
||||||
}));
|
|
||||||
setParticles(generated);
|
|
||||||
}, [particleCount, intensity, shape]);
|
|
||||||
|
|
||||||
const getShapeStyles = (particle: Particle): React.CSSProperties => {
|
|
||||||
const baseStyles: React.CSSProperties = {
|
|
||||||
width: particle.size,
|
|
||||||
height: particle.size,
|
|
||||||
left: `${particle.x}%`,
|
|
||||||
top: `${particle.y}%`,
|
|
||||||
willChange: prefersReducedMotion ? 'auto' : 'transform, opacity',
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (particle.shape) {
|
|
||||||
case 'circle':
|
|
||||||
return {
|
|
||||||
...baseStyles,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: `radial-gradient(circle, ${color} 0%, ${color}80 40%, transparent 70%)`,
|
|
||||||
boxShadow: effect === 'glow' ? `0 0 ${particle.size * 2}px ${color}60` : 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'square':
|
|
||||||
return {
|
|
||||||
...baseStyles,
|
|
||||||
borderRadius: '2px',
|
|
||||||
background: `linear-gradient(135deg, ${color} 0%, ${color}60 100%)`,
|
|
||||||
boxShadow: effect === 'glow' ? `0 0 ${particle.size}px ${color}40` : 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'triangle':
|
|
||||||
return {
|
|
||||||
...baseStyles,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
background: 'transparent',
|
|
||||||
borderLeft: `${particle.size / 2}px solid transparent`,
|
|
||||||
borderRight: `${particle.size / 2}px solid transparent`,
|
|
||||||
borderBottom: `${particle.size}px solid ${color}`,
|
|
||||||
filter: effect === 'glow' ? `drop-shadow(0 0 ${particle.size / 2}px ${color}60)` : 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'diamond':
|
|
||||||
return {
|
|
||||||
...baseStyles,
|
|
||||||
transform: `rotate(45deg)`,
|
|
||||||
background: `linear-gradient(135deg, ${color} 0%, ${color}60 100%)`,
|
|
||||||
boxShadow: effect === 'glow' ? `0 0 ${particle.size}px ${color}40` : 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'star':
|
|
||||||
return {
|
|
||||||
...baseStyles,
|
|
||||||
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
|
|
||||||
background: `radial-gradient(circle, ${color} 0%, ${color}80 100%)`,
|
|
||||||
filter: effect === 'glow' ? `drop-shadow(0 0 ${particle.size / 2}px ${color}60)` : 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return baseStyles;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAnimationVariants = (particle: Particle) => {
|
|
||||||
if (prefersReducedMotion) {
|
|
||||||
return { scale: 1, opacity: particle.opacity };
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseAnimation = {
|
|
||||||
scale: [0, 2, 1.5, 2.5, 0],
|
|
||||||
opacity: [0, particle.opacity, particle.opacity * 0.8, particle.opacity, 0],
|
|
||||||
y: [0, -particle.moveRange * 0.5, -particle.moveRange, -particle.moveRange * 1.5, -particle.moveRange * 2],
|
|
||||||
x: [0, particle.moveRange * 0.3, -particle.moveRange * 0.2, particle.moveRange * 0.15, 0],
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (effect) {
|
|
||||||
case 'pulse':
|
|
||||||
return {
|
|
||||||
...baseAnimation,
|
|
||||||
scale: [0, 1.5, 1, 1.8, 0],
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'glow':
|
|
||||||
return {
|
|
||||||
...baseAnimation,
|
|
||||||
opacity: [0, particle.opacity, particle.opacity * 1.2, particle.opacity, 0],
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'trail':
|
|
||||||
return {
|
|
||||||
...baseAnimation,
|
|
||||||
y: [particle.moveRange * 0.5, -particle.moveRange * 0.5, -particle.moveRange * 1.5, -particle.moveRange * 2.5, -particle.moveRange * 3],
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return baseAnimation;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
|
|
||||||
{particles.map((particle) => (
|
|
||||||
<motion.div
|
|
||||||
key={particle.id}
|
|
||||||
className="absolute"
|
|
||||||
style={getShapeStyles(particle)}
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
animate={getAnimationVariants(particle)}
|
|
||||||
transition={{
|
|
||||||
duration: particle.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: particle.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<svg className="absolute inset-0 w-full h-full opacity-15">
|
|
||||||
<defs>
|
|
||||||
<pattern id="dataGrid" width="60" height="60" patternUnits="userSpaceOnUse">
|
|
||||||
<circle cx="30" cy="30" r="1.5" fill={color} opacity="0.4" />
|
|
||||||
<circle cx="0" cy="0" r="1" fill={color} opacity="0.2" />
|
|
||||||
<circle cx="60" cy="0" r="1" fill={color} opacity="0.2" />
|
|
||||||
<circle cx="0" cy="60" r="1" fill={color} opacity="0.2" />
|
|
||||||
<circle cx="60" cy="60" r="1" fill={color} opacity="0.2" />
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" fill="url(#dataGrid)" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/20" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DataParticleFlow;
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
|
|
||||||
interface FluidWaveBackgroundProps {
|
|
||||||
className?: string;
|
|
||||||
color1?: string;
|
|
||||||
color2?: string;
|
|
||||||
speed?: number;
|
|
||||||
intensity?: number;
|
|
||||||
noiseScale?: number;
|
|
||||||
mouseInfluence?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FluidWaveBackground({
|
|
||||||
className = '',
|
|
||||||
color1 = '#C41E3A',
|
|
||||||
color2 = '#1C1C1C',
|
|
||||||
speed = 0.5,
|
|
||||||
intensity = 1.2,
|
|
||||||
noiseScale = 3.0,
|
|
||||||
mouseInfluence = 0.8
|
|
||||||
}: FluidWaveBackgroundProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
|
||||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
|
||||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
|
||||||
const meshRef = useRef<THREE.Mesh | null>(null);
|
|
||||||
const animationRef = useRef<number | undefined>(undefined);
|
|
||||||
const mouseRef = useRef({ x: 0, y: 0, active: false });
|
|
||||||
|
|
||||||
const vertexShader = `
|
|
||||||
varying vec2 vUv;
|
|
||||||
varying float vElevation;
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uIntensity;
|
|
||||||
uniform float uNoiseScale;
|
|
||||||
uniform vec2 uMouse;
|
|
||||||
uniform float uMouseInfluence;
|
|
||||||
uniform float uMouseActive;
|
|
||||||
|
|
||||||
float random(vec2 st) {
|
|
||||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
|
||||||
}
|
|
||||||
|
|
||||||
float noise(vec2 st) {
|
|
||||||
vec2 i = floor(st);
|
|
||||||
vec2 f = fract(st);
|
|
||||||
float a = random(i);
|
|
||||||
float b = random(i + vec2(1.0, 0.0));
|
|
||||||
float c = random(i + vec2(0.0, 1.0));
|
|
||||||
float d = random(i + vec2(1.0, 1.0));
|
|
||||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
||||||
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
float fbm(vec2 st) {
|
|
||||||
float value = 0.0;
|
|
||||||
float amplitude = 0.5;
|
|
||||||
for (int i = 0; i < 4; i++) {
|
|
||||||
value += amplitude * noise(st);
|
|
||||||
st *= 2.0;
|
|
||||||
amplitude *= 0.5;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
vec2 pos = position.xy * uNoiseScale;
|
|
||||||
float elevation = fbm(pos + uTime * 0.1);
|
|
||||||
|
|
||||||
if (uMouseActive > 0.5) {
|
|
||||||
float dist = distance(uv, uMouse);
|
|
||||||
float mouseEffect = smoothstep(0.3, 0.0, dist) * uMouseInfluence;
|
|
||||||
elevation += mouseEffect * sin(uTime * 2.0 + dist * 10.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
vElevation = elevation * uIntensity;
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, vElevation, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const fragmentShader = `
|
|
||||||
varying vec2 vUv;
|
|
||||||
varying float vElevation;
|
|
||||||
uniform vec3 uColor1;
|
|
||||||
uniform vec3 uColor2;
|
|
||||||
uniform float uTime;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
float mixFactor = smoothstep(-0.5, 0.5, vElevation);
|
|
||||||
vec3 color = mix(uColor2, uColor1, mixFactor);
|
|
||||||
|
|
||||||
float highlight = smoothstep(0.3, 0.5, vElevation) * 0.3;
|
|
||||||
color += vec3(highlight);
|
|
||||||
|
|
||||||
float alpha = 0.6 + vElevation * 0.2;
|
|
||||||
gl_FragColor = vec4(color, alpha);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) {return;}
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const width = container.clientWidth;
|
|
||||||
const height = container.clientHeight;
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
sceneRef.current = scene;
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
|
||||||
camera.position.z = 5;
|
|
||||||
cameraRef.current = camera;
|
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({
|
|
||||||
alpha: true,
|
|
||||||
antialias: true,
|
|
||||||
powerPreference: 'high-performance'
|
|
||||||
});
|
|
||||||
renderer.setSize(width, height);
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
||||||
container.appendChild(renderer.domElement);
|
|
||||||
rendererRef.current = renderer;
|
|
||||||
|
|
||||||
const geometry = new THREE.PlaneGeometry(10, 10, 128, 128);
|
|
||||||
|
|
||||||
const uniforms = {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uColor1: { value: new THREE.Color(color1) },
|
|
||||||
uColor2: { value: new THREE.Color(color2) },
|
|
||||||
uIntensity: { value: intensity },
|
|
||||||
uNoiseScale: { value: noiseScale },
|
|
||||||
uMouse: { value: new THREE.Vector2(0, 0) },
|
|
||||||
uMouseInfluence: { value: mouseInfluence },
|
|
||||||
uMouseActive: { value: 0 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const material = new THREE.ShaderMaterial({
|
|
||||||
uniforms,
|
|
||||||
vertexShader,
|
|
||||||
fragmentShader,
|
|
||||||
transparent: true,
|
|
||||||
side: THREE.DoubleSide
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.rotation.x = -Math.PI / 4;
|
|
||||||
scene.add(mesh);
|
|
||||||
meshRef.current = mesh;
|
|
||||||
|
|
||||||
const animate = (time: number) => {
|
|
||||||
if (meshRef.current && rendererRef.current && sceneRef.current && cameraRef.current) {
|
|
||||||
const material = meshRef.current.material as THREE.ShaderMaterial;
|
|
||||||
if (material.uniforms.uTime) {
|
|
||||||
material.uniforms.uTime.value = time * speed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mouseRef.current.active) {
|
|
||||||
if (material.uniforms.uMouse) {
|
|
||||||
material.uniforms.uMouse.value.x = mouseRef.current.x;
|
|
||||||
material.uniforms.uMouse.value.y = mouseRef.current.y;
|
|
||||||
}
|
|
||||||
if (material.uniforms.uMouseActive) {
|
|
||||||
material.uniforms.uMouseActive.value = 1.0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (material.uniforms.uMouseActive) {
|
|
||||||
material.uniforms.uMouseActive.value = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rendererRef.current.render(sceneRef.current, cameraRef.current);
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
|
||||||
if (!containerRef.current) {return;}
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
mouseRef.current.x = (event.clientX - rect.left) / rect.width;
|
|
||||||
mouseRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
|
|
||||||
mouseRef.current.active = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
mouseRef.current.active = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
containerRef.current.addEventListener('mousemove', handleMouseMove);
|
|
||||||
containerRef.current.addEventListener('mouseleave', handleMouseLeave);
|
|
||||||
|
|
||||||
animate(0);
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
if (!containerRef.current || !cameraRef.current || !rendererRef.current) {return;}
|
|
||||||
|
|
||||||
const newWidth = containerRef.current.clientWidth;
|
|
||||||
const newHeight = containerRef.current.clientHeight;
|
|
||||||
|
|
||||||
cameraRef.current.aspect = newWidth / newHeight;
|
|
||||||
cameraRef.current.updateProjectionMatrix();
|
|
||||||
rendererRef.current.setSize(newWidth, newHeight);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(handleResize);
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
containerRef.current?.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
containerRef.current?.removeEventListener('mouseleave', handleMouseLeave);
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
}
|
|
||||||
if (rendererRef.current) {
|
|
||||||
rendererRef.current.dispose();
|
|
||||||
container.removeChild(rendererRef.current.domElement);
|
|
||||||
}
|
|
||||||
if (meshRef.current) {
|
|
||||||
meshRef.current.geometry.dispose();
|
|
||||||
(meshRef.current.material as THREE.ShaderMaterial).dispose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [color1, color2, speed, intensity, noiseScale, vertexShader, fragmentShader]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`absolute inset-0 pointer-events-none ${className}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FluidWaveBackground;
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface GeometricAbstractProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: 'minimal' | 'complex' | 'dynamic';
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Shape {
|
|
||||||
id: number;
|
|
||||||
type: 'circle' | 'square' | 'triangle';
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
size: number;
|
|
||||||
rotation: number;
|
|
||||||
opacity: number;
|
|
||||||
duration: number;
|
|
||||||
delay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeometricAbstract({
|
|
||||||
className = '',
|
|
||||||
variant = 'minimal',
|
|
||||||
color = '#C41E3A',
|
|
||||||
}: GeometricAbstractProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const [shapes, setShapes] = useState<Shape[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const count = variant === 'complex' ? 15 : variant === 'dynamic' ? 20 : 8;
|
|
||||||
const generated: Shape[] = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
type: ['circle', 'square', 'triangle'][Math.floor(Math.random() * 3)] as Shape['type'],
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
size: Math.random() * 100 + 50,
|
|
||||||
rotation: Math.random() * 360,
|
|
||||||
opacity: Math.random() * 0.08 + 0.02,
|
|
||||||
duration: Math.random() * 20 + 15,
|
|
||||||
delay: Math.random() * 3,
|
|
||||||
}));
|
|
||||||
setShapes(generated);
|
|
||||||
}, [variant]);
|
|
||||||
|
|
||||||
const renderShape = (shape: Shape) => {
|
|
||||||
const baseStyle = {
|
|
||||||
position: 'absolute' as const,
|
|
||||||
left: `${shape.x}%`,
|
|
||||||
top: `${shape.y}%`,
|
|
||||||
width: shape.size,
|
|
||||||
height: shape.size,
|
|
||||||
opacity: shape.opacity,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (shape.type) {
|
|
||||||
case 'circle':
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={shape.id}
|
|
||||||
style={{
|
|
||||||
...baseStyle,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: `1px solid ${color}`,
|
|
||||||
background: `radial-gradient(circle, ${color}10 0%, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
scale: [1, 1.2, 1],
|
|
||||||
rotate: [0, 180, 360],
|
|
||||||
opacity: [shape.opacity, shape.opacity * 1.5, shape.opacity],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: shape.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: shape.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'square':
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={shape.id}
|
|
||||||
style={{
|
|
||||||
...baseStyle,
|
|
||||||
border: `1px solid ${color}`,
|
|
||||||
background: `linear-gradient(135deg, ${color}08 0%, transparent 100%)`,
|
|
||||||
}}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
rotate: [shape.rotation, shape.rotation + 90, shape.rotation],
|
|
||||||
scale: [1, 1.1, 1],
|
|
||||||
opacity: [shape.opacity, shape.opacity * 1.3, shape.opacity],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: shape.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: shape.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'triangle':
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={shape.id}
|
|
||||||
style={{
|
|
||||||
...baseStyle,
|
|
||||||
clipPath: 'polygon(50% 0%, 0% 100%, 100% 100%)',
|
|
||||||
background: `linear-gradient(135deg, ${color}10 0%, transparent 100%)`,
|
|
||||||
}}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
rotate: [0, 120, 240, 360],
|
|
||||||
scale: [1, 1.15, 1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: shape.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: shape.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
|
|
||||||
{shapes.map(renderShape)}
|
|
||||||
|
|
||||||
<svg className="absolute inset-0 w-full h-full opacity-5">
|
|
||||||
<defs>
|
|
||||||
<pattern id="geoGrid" width="60" height="60" patternUnits="userSpaceOnUse">
|
|
||||||
<path d="M 60 0 L 0 0 0 60" fill="none" stroke={color} strokeWidth="0.5" />
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" fill="url(#geoGrid)" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/40" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GeometricAbstract;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface GeometricShapeProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeometricShapes({
|
|
||||||
className = '',
|
|
||||||
color = '#C41E3A'
|
|
||||||
}: GeometricShapeProps) {
|
|
||||||
const shapes = [
|
|
||||||
{ type: 'circle', size: 120, x: 10, y: 15, delay: 0 },
|
|
||||||
{ type: 'square', size: 80, x: 80, y: 20, delay: 1 },
|
|
||||||
{ type: 'triangle', size: 60, x: 70, y: 60, delay: 2 },
|
|
||||||
{ type: 'circle', size: 40, x: 20, y: 70, delay: 3 },
|
|
||||||
{ type: 'square', size: 50, x: 85, y: 75, delay: 4 }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 pointer-events-none ${className}`}>
|
|
||||||
{shapes.map((shape, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className="absolute border-2"
|
|
||||||
style={{
|
|
||||||
borderColor: `${color}20`,
|
|
||||||
width: shape.size,
|
|
||||||
height: shape.size,
|
|
||||||
left: `${shape.x}%`,
|
|
||||||
top: `${shape.y}%`,
|
|
||||||
borderRadius: shape.type === 'circle' ? '50%' : '0',
|
|
||||||
transform: shape.type === 'triangle' ? 'rotate(0deg)' : 'rotate(0deg)'
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0, rotate: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 0.15, 0.15, 0],
|
|
||||||
scale: [0, 1, 1, 0],
|
|
||||||
rotate: [0, 45, 45, 0]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 10,
|
|
||||||
delay: shape.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.2, 0.8, 1]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GeometricShapes;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface GlowEffectProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlowEffect({
|
|
||||||
className = '',
|
|
||||||
color = '#C41E3A',
|
|
||||||
count = 3
|
|
||||||
}: GlowEffectProps) {
|
|
||||||
const [glows, setGlows] = useState<Array<{
|
|
||||||
id: number;
|
|
||||||
size: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
delay: number;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const generatedGlows = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
size: 150 + Math.random() * 100,
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
delay: i * 2
|
|
||||||
}));
|
|
||||||
setGlows(generatedGlows);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
if (glows.length === 0) {
|
|
||||||
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 pointer-events-none ${className}`}>
|
|
||||||
{glows.map((glow) => (
|
|
||||||
<motion.div
|
|
||||||
key={glow.id}
|
|
||||||
className="absolute rounded-full"
|
|
||||||
style={{
|
|
||||||
width: glow.size,
|
|
||||||
height: glow.size,
|
|
||||||
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
|
|
||||||
left: `${glow.x}%`,
|
|
||||||
top: `${glow.y}%`,
|
|
||||||
transform: 'translate(-50%, -50%)'
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 0.4, 0.4, 0],
|
|
||||||
scale: [0.5, 1.2, 1.2, 0.5]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 8,
|
|
||||||
delay: glow.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.3, 0.7, 1]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GlowEffect;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface GradientAnimationProps {
|
|
||||||
className?: string;
|
|
||||||
colors?: string[];
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GradientAnimation({
|
|
||||||
className = '',
|
|
||||||
colors = ['#C41E3A', '#1C1C1C', '#D4A574'],
|
|
||||||
duration = 8
|
|
||||||
}: GradientAnimationProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute inset-0 ${className}`}
|
|
||||||
animate={{
|
|
||||||
background: colors.map((color, i) =>
|
|
||||||
`${color} ${100 / colors.length * i}% ${100 / colors.length * (i + 1)}%`
|
|
||||||
).join(', ')
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'linear'
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
backgroundSize: '400% 400%',
|
|
||||||
backgroundPosition: '0% 50%'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GradientAnimation;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface GradientFlowOptimizedProps {
|
|
||||||
className?: string;
|
|
||||||
colors?: string[];
|
|
||||||
duration?: number;
|
|
||||||
variant?: 'smooth' | 'dynamic' | 'minimal';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GradientFlowOptimized({
|
|
||||||
className = '',
|
|
||||||
colors = ['#FAFAFA', '#FFE8EC', '#FFF0F3', '#F5F5F5', '#FFD6DD'],
|
|
||||||
duration = 15,
|
|
||||||
variant = 'smooth',
|
|
||||||
}: GradientFlowOptimizedProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const gradientStyle = {
|
|
||||||
background: `linear-gradient(135deg, ${colors.join(', ')})`,
|
|
||||||
backgroundSize: '400% 400%',
|
|
||||||
};
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
smooth: {
|
|
||||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
|
|
||||||
},
|
|
||||||
dynamic: {
|
|
||||||
backgroundPosition: ['0% 0%', '100% 100%', '0% 50%', '100% 0%', '0% 0%'],
|
|
||||||
},
|
|
||||||
minimal: {
|
|
||||||
backgroundPosition: ['0% 50%', '50% 50%', '0% 50%'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (prefersReducedMotion) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 ${className}`}
|
|
||||||
style={{
|
|
||||||
...gradientStyle,
|
|
||||||
backgroundPosition: '50% 50%',
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
...gradientStyle,
|
|
||||||
willChange: 'background-position',
|
|
||||||
}}
|
|
||||||
animate={variants[variant]}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'linear',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[100px] opacity-50" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GradientFlowOptimized;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { render } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { GradientFlow } from './gradient-flow';
|
|
||||||
|
|
||||||
jest.mock('framer-motion', () => ({
|
|
||||||
motion: {
|
|
||||||
div: ({ className }: { className?: string }) => <div className={className} data-testid="gradient-flow" />,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('GradientFlow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render gradient flow component', () => {
|
|
||||||
const { getByTestId } = render(<GradientFlow />);
|
|
||||||
const component = getByTestId('gradient-flow');
|
|
||||||
expect(component).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', () => {
|
|
||||||
const { getByTestId } = render(<GradientFlow className="custom-class" />);
|
|
||||||
const component = getByTestId('gradient-flow');
|
|
||||||
expect(component).toHaveClass('custom-class');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Props', () => {
|
|
||||||
it('should accept custom colors', () => {
|
|
||||||
const { getByTestId } = render(
|
|
||||||
<GradientFlow colors={['#ff0000', '#00ff00', '#0000ff']} />
|
|
||||||
);
|
|
||||||
const component = getByTestId('gradient-flow');
|
|
||||||
expect(component).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom duration', () => {
|
|
||||||
const { getByTestId } = render(<GradientFlow duration={20} />);
|
|
||||||
const component = getByTestId('gradient-flow');
|
|
||||||
expect(component).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface GradientFlowProps {
|
|
||||||
className?: string;
|
|
||||||
colors?: string[];
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GradientFlow({
|
|
||||||
className = '',
|
|
||||||
colors = ['#C41E3A', '#D4A574', '#8B4513', '#2F4F4F'],
|
|
||||||
duration = 15
|
|
||||||
}: GradientFlowProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute inset-0 ${className}`}
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(-45deg, ${colors.join(', ')})`,
|
|
||||||
backgroundSize: '400% 400%'
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%']
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'linear'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GradientFlow;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface GradientGridProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
gridSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GradientGrid({
|
|
||||||
className = '',
|
|
||||||
color = '#C41E3A',
|
|
||||||
gridSize = 8
|
|
||||||
}: GradientGridProps) {
|
|
||||||
const cells = Array.from({ length: gridSize }, (_, row) =>
|
|
||||||
Array.from({ length: gridSize }, (_, col) => ({
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
delay: (row + col) * 0.1
|
|
||||||
}))
|
|
||||||
).flat();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 pointer-events-none ${className}`}
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
|
|
||||||
gap: '1px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cells.map((cell, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(135deg, ${color}05 0%, ${color}10 100%)`
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 0.3, 0.3, 0]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 6,
|
|
||||||
delay: cell.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.3, 0.7, 1]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GradientGrid;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface GradientOrbsProps {
|
|
||||||
className?: string;
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Orb {
|
|
||||||
id: number;
|
|
||||||
size: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
color: string;
|
|
||||||
duration: number;
|
|
||||||
delay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorPalette = [
|
|
||||||
'rgba(196, 30, 58, 0.15)',
|
|
||||||
'rgba(255, 232, 236, 0.2)',
|
|
||||||
'rgba(255, 240, 243, 0.18)',
|
|
||||||
'rgba(245, 245, 245, 0.15)',
|
|
||||||
'rgba(255, 214, 221, 0.2)',
|
|
||||||
'rgba(224, 74, 104, 0.12)',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GradientOrbs({ className = '', count = 5 }: GradientOrbsProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const [orbs, setOrbs] = useState<Orb[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const generatedOrbs: Orb[] = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
size: Math.random() * 400 + 200,
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
color: colorPalette[i % colorPalette.length] ?? 'rgba(196, 30, 58, 0.15)',
|
|
||||||
duration: Math.random() * 20 + 15,
|
|
||||||
delay: Math.random() * 5,
|
|
||||||
}));
|
|
||||||
setOrbs(generatedOrbs);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 overflow-hidden ${className}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{orbs.map((orb) => (
|
|
||||||
<motion.div
|
|
||||||
key={orb.id}
|
|
||||||
className="absolute rounded-full"
|
|
||||||
style={{
|
|
||||||
width: orb.size,
|
|
||||||
height: orb.size,
|
|
||||||
left: `${orb.x}%`,
|
|
||||||
top: `${orb.y}%`,
|
|
||||||
background: `radial-gradient(circle, ${orb.color} 0%, transparent 70%)`,
|
|
||||||
willChange: prefersReducedMotion ? 'auto' : 'transform',
|
|
||||||
filter: 'blur(60px)',
|
|
||||||
}}
|
|
||||||
initial={{
|
|
||||||
x: '-50%',
|
|
||||||
y: '-50%',
|
|
||||||
scale: 1,
|
|
||||||
}}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
x: ['-50%', '-40%', '-60%', '-50%'],
|
|
||||||
y: ['-50%', '-60%', '-40%', '-50%'],
|
|
||||||
scale: [1, 1.2, 0.9, 1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: orb.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: orb.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/20 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GradientOrbs;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface GridLinesProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
density?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GridLines({
|
|
||||||
className = '',
|
|
||||||
color = '#C41E3A',
|
|
||||||
density = 6
|
|
||||||
}: GridLinesProps) {
|
|
||||||
const [lines, setLines] = useState<Array<{
|
|
||||||
id: number;
|
|
||||||
delay: number;
|
|
||||||
duration: number;
|
|
||||||
top: number;
|
|
||||||
width: number;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const generatedLines = Array.from({ length: density }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
delay: i * 0.5,
|
|
||||||
duration: 6 + Math.random() * 4,
|
|
||||||
top: 20 + Math.random() * 60,
|
|
||||||
width: 30 + Math.random() * 40
|
|
||||||
}));
|
|
||||||
setLines(generatedLines);
|
|
||||||
}, [density]);
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 pointer-events-none ${className}`}>
|
|
||||||
{lines.map((line) => (
|
|
||||||
<motion.div
|
|
||||||
key={line.id}
|
|
||||||
className="absolute h-px"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${color}10`,
|
|
||||||
left: `${(line.id / density) * 100}%`,
|
|
||||||
top: `${line.top}%`,
|
|
||||||
width: `${line.width}%`
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scaleX: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 0.2, 0.2, 0],
|
|
||||||
scaleX: [0, 1, 1, 0]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: line.duration,
|
|
||||||
delay: line.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.3, 0.7, 1]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GridLines;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export { DataParticleFlow } from './data-particle-flow';
|
|
||||||
export { SubtleDots } from './subtle-dots';
|
|
||||||
export { SubtleParticles } from './subtle-particles';
|
|
||||||
export { ParticleGalaxy } from './particle-galaxy';
|
|
||||||
export { MouseInteractiveParticles } from './mouse-interactive-particles';
|
|
||||||
export { GradientFlow } from './gradient-flow';
|
|
||||||
export { GradientAnimation } from './gradient-animation';
|
|
||||||
export { GradientOrbs } from './gradient-orbs';
|
|
||||||
export { GradientGrid } from './gradient-grid';
|
|
||||||
export { TechGridFlow } from './tech-grid-flow';
|
|
||||||
export { MeshGradient } from './mesh-gradient';
|
|
||||||
export { InkTechFusion } from './ink-tech-fusion';
|
|
||||||
export { GridLines } from './grid-lines';
|
|
||||||
export { GlowEffect } from './glow-effect';
|
|
||||||
export { GeometricShapes } from './geometric-shapes';
|
|
||||||
export { GeometricAbstract } from './geometric-abstract';
|
|
||||||
export { FluidWaveBackground } from './fluid-wave-background';
|
|
||||||
export { AdvancedFloatingEffects } from './advanced-floating-effects';
|
|
||||||
export { ParallaxEffect } from './parallax-effect';
|
|
||||||
export { SealAnimationEnhanced } from './seal-animation-enhanced';
|
|
||||||
export { InkDataMorph } from './ink-data-morph';
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import { render } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { InkDataMorph } from './ink-data-morph';
|
|
||||||
|
|
||||||
jest.mock('@/hooks/use-reduced-motion', () => ({
|
|
||||||
useReducedMotion: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
class MockCanvasContext {
|
|
||||||
fillStyle = '';
|
|
||||||
strokeStyle = '';
|
|
||||||
lineWidth = 0;
|
|
||||||
lineCap = '';
|
|
||||||
globalAlpha = 1;
|
|
||||||
globalCompositeOperation = 'source-over';
|
|
||||||
beginPath = jest.fn();
|
|
||||||
arc = jest.fn();
|
|
||||||
ellipse = jest.fn();
|
|
||||||
fill = jest.fn();
|
|
||||||
stroke = jest.fn();
|
|
||||||
moveTo = jest.fn();
|
|
||||||
lineTo = jest.fn();
|
|
||||||
quadraticCurveTo = jest.fn();
|
|
||||||
closePath = jest.fn();
|
|
||||||
save = jest.fn();
|
|
||||||
restore = jest.fn();
|
|
||||||
clearRect = jest.fn();
|
|
||||||
fillRect = jest.fn();
|
|
||||||
setTransform = jest.fn();
|
|
||||||
translate = jest.fn();
|
|
||||||
rotate = jest.fn();
|
|
||||||
createRadialGradient = jest.fn(() => ({
|
|
||||||
addColorStop: jest.fn(),
|
|
||||||
}));
|
|
||||||
createLinearGradient = jest.fn(() => ({
|
|
||||||
addColorStop: jest.fn(),
|
|
||||||
}));
|
|
||||||
createPattern = jest.fn(() => ({}));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('InkDataMorph', () => {
|
|
||||||
let mockContext: MockCanvasContext;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockContext = new MockCanvasContext();
|
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(mockContext);
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'width', {
|
|
||||||
configurable: true,
|
|
||||||
value: 1920,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'height', {
|
|
||||||
configurable: true,
|
|
||||||
value: 1080,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render a canvas element', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 960, height: 540 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 960, height: 540 }}>
|
|
||||||
<InkDataMorph className="custom-class" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toHaveClass('custom-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have aria-hidden attribute', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 960, height: 540 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toHaveAttribute('aria-hidden', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have pointer-events-none class', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 960, height: 540 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toHaveClass('pointer-events-none');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Canvas initialization', () => {
|
|
||||||
it('should render canvas with correct dimensions', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 960, height: 540 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Responsive behavior', () => {
|
|
||||||
it('should render on mobile viewport with reduced effects', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 375, height: 667 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render on tablet viewport', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 900, height: 600 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render on desktop viewport with full effects', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 1440, height: 900 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Ink wash enhancement', () => {
|
|
||||||
it('should render without errors on mobile without feibai', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 375, height: 667 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render without errors on tablet with feibai', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 900, height: 600 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render without errors on desktop with full effects', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 1440, height: 900 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render canvas with correct aria attributes', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 1440, height: 900 }}>
|
|
||||||
<InkDataMorph />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toHaveAttribute('aria-hidden', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom className prop', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<div style={{ width: 1440, height: 900 }}>
|
|
||||||
<InkDataMorph className="ink-enhanced" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const canvas = container.querySelector('canvas');
|
|
||||||
expect(canvas).toHaveClass('ink-enhanced');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,913 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
|
||||||
|
|
||||||
interface InkDataMorphProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrailPoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
radius: number;
|
|
||||||
initialRadius: number;
|
|
||||||
dataRadius: number;
|
|
||||||
opacity: number;
|
|
||||||
toneIndex: number;
|
|
||||||
rotation: number;
|
|
||||||
scaleX: number;
|
|
||||||
scaleY: number;
|
|
||||||
isSplash: boolean;
|
|
||||||
phase: 'spreading' | 'settling' | 'morphing' | 'complete' | 'fading';
|
|
||||||
spreadTime: number;
|
|
||||||
maxSpreadTime: number;
|
|
||||||
settleTime: number;
|
|
||||||
morphProgress: number;
|
|
||||||
targetX: number;
|
|
||||||
targetY: number;
|
|
||||||
delay: number;
|
|
||||||
age: number;
|
|
||||||
trail: TrailPoint[];
|
|
||||||
seed1: number;
|
|
||||||
seed2: number;
|
|
||||||
wobbleFactor: number;
|
|
||||||
prevVx: number;
|
|
||||||
prevVy: number;
|
|
||||||
inkLayerCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResponsiveConfig {
|
|
||||||
particleCount: number;
|
|
||||||
centers: { x: number; y: number }[];
|
|
||||||
centerDistribution: number[];
|
|
||||||
targetXRange: [number, number];
|
|
||||||
targetYRange: [number, number];
|
|
||||||
connectionDistance: number;
|
|
||||||
glowScale: number;
|
|
||||||
gradientLayers: number;
|
|
||||||
maxTrail: number;
|
|
||||||
splashRatio: number;
|
|
||||||
bgOrbScale: number;
|
|
||||||
inkLayers: number;
|
|
||||||
inkRadiusScale: number;
|
|
||||||
inkStringThreshold: number;
|
|
||||||
showFeibai: boolean;
|
|
||||||
paperTextureSize: number;
|
|
||||||
wobbleDetail: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TONES = [
|
|
||||||
{ hex: '#1C1C1C', rgb: '28, 28, 28', weight: 0.15 },
|
|
||||||
{ hex: '#4A4A4A', rgb: '74, 74, 74', weight: 0.25 },
|
|
||||||
{ hex: '#8C8C8C', rgb: '140, 140, 140', weight: 0.30 },
|
|
||||||
{ hex: '#C41E3A', rgb: '196, 30, 58', weight: 0.10 },
|
|
||||||
{ hex: '#E8707A', rgb: '232, 112, 122', weight: 0.20 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ACCENT_TONES = [3, 4];
|
|
||||||
|
|
||||||
function pickToneIndex(isSplash: boolean): number {
|
|
||||||
if (isSplash) {
|
|
||||||
return Math.random() < 0.4 ? 0 : 1;
|
|
||||||
}
|
|
||||||
const r = Math.random();
|
|
||||||
let cumulative = 0;
|
|
||||||
for (let i = 0; i < TONES.length; i++) {
|
|
||||||
const tone = TONES[i];
|
|
||||||
if (!tone) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cumulative += tone.weight;
|
|
||||||
if (r < cumulative) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResponsiveConfig(W: number): ResponsiveConfig {
|
|
||||||
if (W < 768) {
|
|
||||||
return {
|
|
||||||
particleCount: 70,
|
|
||||||
centers: [{ x: 0.65, y: 0.3 }],
|
|
||||||
centerDistribution: [1],
|
|
||||||
targetXRange: [0.55, 0.9],
|
|
||||||
targetYRange: [0.1, 0.5],
|
|
||||||
connectionDistance: 60,
|
|
||||||
glowScale: 0.5,
|
|
||||||
gradientLayers: 2,
|
|
||||||
maxTrail: 2,
|
|
||||||
splashRatio: 0,
|
|
||||||
bgOrbScale: 0.45,
|
|
||||||
inkLayers: 3,
|
|
||||||
inkRadiusScale: 1.2,
|
|
||||||
inkStringThreshold: 0.35,
|
|
||||||
showFeibai: false,
|
|
||||||
paperTextureSize: 128,
|
|
||||||
wobbleDetail: 16,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (W < 1024) {
|
|
||||||
return {
|
|
||||||
particleCount: 120,
|
|
||||||
centers: [
|
|
||||||
{ x: 0.68, y: 0.3 },
|
|
||||||
{ x: 0.3, y: 0.6 },
|
|
||||||
],
|
|
||||||
centerDistribution: [0.65, 0.35],
|
|
||||||
targetXRange: [0.5, 0.9],
|
|
||||||
targetYRange: [0.12, 0.6],
|
|
||||||
connectionDistance: 80,
|
|
||||||
glowScale: 0.75,
|
|
||||||
gradientLayers: 2,
|
|
||||||
maxTrail: 4,
|
|
||||||
splashRatio: 0.05,
|
|
||||||
bgOrbScale: 0.7,
|
|
||||||
inkLayers: 3,
|
|
||||||
inkRadiusScale: 1.1,
|
|
||||||
inkStringThreshold: 0.4,
|
|
||||||
showFeibai: true,
|
|
||||||
paperTextureSize: 128,
|
|
||||||
wobbleDetail: 20,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
particleCount: 180,
|
|
||||||
centers: [
|
|
||||||
{ x: 0.72, y: 0.3 },
|
|
||||||
{ x: 0.25, y: 0.65 },
|
|
||||||
],
|
|
||||||
centerDistribution: [0.67, 0.33],
|
|
||||||
targetXRange: [0.5, 0.9],
|
|
||||||
targetYRange: [0.12, 0.65],
|
|
||||||
connectionDistance: 100,
|
|
||||||
glowScale: 1.0,
|
|
||||||
gradientLayers: 3,
|
|
||||||
maxTrail: 6,
|
|
||||||
splashRatio: 0.1,
|
|
||||||
bgOrbScale: 1.0,
|
|
||||||
inkLayers: 4,
|
|
||||||
inkRadiusScale: 1.0,
|
|
||||||
inkStringThreshold: 0.5,
|
|
||||||
showFeibai: true,
|
|
||||||
paperTextureSize: 256,
|
|
||||||
wobbleDetail: 24,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePaperTexture(size: number): HTMLCanvasElement {
|
|
||||||
const offscreen = document.createElement('canvas');
|
|
||||||
offscreen.width = size;
|
|
||||||
offscreen.height = size;
|
|
||||||
const octx = offscreen.getContext('2d');
|
|
||||||
if (!octx) {
|
|
||||||
return offscreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
octx.fillStyle = '#FAFAF5';
|
|
||||||
octx.fillRect(0, 0, size, size);
|
|
||||||
|
|
||||||
for (let i = 0; i < size * size * 0.15; i++) {
|
|
||||||
const x = Math.random() * size;
|
|
||||||
const y = Math.random() * size;
|
|
||||||
const gray = 200 + Math.floor(Math.random() * 40);
|
|
||||||
const alpha = 0.02 + Math.random() * 0.04;
|
|
||||||
octx.fillStyle = `rgba(${gray}, ${gray}, ${gray}, ${alpha})`;
|
|
||||||
octx.fillRect(x, y, 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
octx.strokeStyle = 'rgba(180, 175, 168, 0.015)';
|
|
||||||
octx.lineWidth = 0.5;
|
|
||||||
for (let i = 0; i < size * 0.3; i++) {
|
|
||||||
const y = Math.random() * size;
|
|
||||||
const startX = Math.random() * size * 0.3;
|
|
||||||
const endX = startX + size * (0.1 + Math.random() * 0.4);
|
|
||||||
octx.beginPath();
|
|
||||||
octx.moveTo(startX, y);
|
|
||||||
octx.lineTo(Math.min(endX, size), y + (Math.random() - 0.5) * 2);
|
|
||||||
octx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
return offscreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPaperTexture(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
W: number,
|
|
||||||
H: number,
|
|
||||||
texture: HTMLCanvasElement,
|
|
||||||
): void {
|
|
||||||
const pattern = ctx.createPattern(texture, 'repeat');
|
|
||||||
if (!pattern) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = pattern;
|
|
||||||
ctx.globalAlpha = 0.03;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeInOutCubic(t: number): number {
|
|
||||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeOutCubic(t: number): number {
|
|
||||||
return 1 - Math.pow(1 - t, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number): number {
|
|
||||||
return a + (b - a) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateParticle(p: Particle, maxTrail: number): void {
|
|
||||||
p.age++;
|
|
||||||
if (p.age < p.delay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.phase === 'spreading') {
|
|
||||||
if (maxTrail > 0) {
|
|
||||||
p.trail.push({ x: p.x, y: p.y });
|
|
||||||
if (p.trail.length > maxTrail) {
|
|
||||||
p.trail.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.prevVx = p.vx;
|
|
||||||
p.prevVy = p.vy;
|
|
||||||
p.x += p.vx;
|
|
||||||
p.y += p.vy;
|
|
||||||
p.vx *= 0.98;
|
|
||||||
p.vy *= 0.98;
|
|
||||||
p.spreadTime++;
|
|
||||||
|
|
||||||
const spreadRatio = p.spreadTime / p.maxSpreadTime;
|
|
||||||
if (spreadRatio < 0.3) {
|
|
||||||
p.radius = p.initialRadius * (1 + spreadRatio * 2);
|
|
||||||
} else {
|
|
||||||
p.radius = p.initialRadius * lerp(1.6, 1, easeOutCubic((spreadRatio - 0.3) / 0.7));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.spreadTime >= p.maxSpreadTime) {
|
|
||||||
if (p.isSplash) {
|
|
||||||
p.phase = 'fading';
|
|
||||||
} else {
|
|
||||||
p.phase = 'settling';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (p.phase === 'settling') {
|
|
||||||
p.settleTime++;
|
|
||||||
p.opacity = Math.max(0.2, p.opacity - 0.002);
|
|
||||||
p.x += (Math.random() - 0.5) * 0.15;
|
|
||||||
p.y += (Math.random() - 0.5) * 0.15;
|
|
||||||
if (p.settleTime > 60) {
|
|
||||||
p.phase = 'morphing';
|
|
||||||
}
|
|
||||||
} else if (p.phase === 'morphing') {
|
|
||||||
p.morphProgress = Math.min(1, p.morphProgress + 0.006);
|
|
||||||
const t = easeInOutCubic(p.morphProgress);
|
|
||||||
p.x = lerp(p.x, p.targetX, t * 0.025);
|
|
||||||
p.y = lerp(p.y, p.targetY, t * 0.025);
|
|
||||||
p.radius = lerp(p.radius, p.dataRadius, t * 0.02);
|
|
||||||
p.opacity = lerp(p.opacity, 0.35, t * 0.008);
|
|
||||||
if (p.morphProgress >= 1) {
|
|
||||||
p.phase = 'complete';
|
|
||||||
}
|
|
||||||
} else if (p.phase === 'complete') {
|
|
||||||
p.opacity = lerp(p.opacity, 0.35, 0.01);
|
|
||||||
} else if (p.phase === 'fading') {
|
|
||||||
p.opacity -= 0.01;
|
|
||||||
p.radius *= 0.98;
|
|
||||||
if (p.opacity <= 0) {
|
|
||||||
p.opacity = 0;
|
|
||||||
p.phase = 'complete';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawTrail(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
p: Particle,
|
|
||||||
rgb: string,
|
|
||||||
): void {
|
|
||||||
if (p.trail.length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
for (let i = 1; i < p.trail.length; i++) {
|
|
||||||
const prev = p.trail[i - 1];
|
|
||||||
const curr = p.trail[i];
|
|
||||||
if (!prev || !curr) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const trailAlpha = (i / p.trail.length) * p.opacity * 0.3;
|
|
||||||
const trailWidth = (i / p.trail.length) * p.radius * 0.8;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(prev.x, prev.y);
|
|
||||||
ctx.lineTo(curr.x, curr.y);
|
|
||||||
ctx.strokeStyle = `rgba(${rgb}, ${trailAlpha})`;
|
|
||||||
ctx.lineWidth = Math.max(0.3, trailWidth);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawInkDot(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
r: number,
|
|
||||||
rotation: number,
|
|
||||||
scaleX: number,
|
|
||||||
scaleY: number,
|
|
||||||
rgb: string,
|
|
||||||
opacity: number,
|
|
||||||
): void {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(x, y);
|
|
||||||
ctx.rotate(rotation);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(0, 0, Math.max(0.5, r * scaleX), Math.max(0.5, r * scaleY), 0, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = `rgba(${rgb}, ${opacity})`;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawInkBlob(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
baseR: number,
|
|
||||||
seed1: number,
|
|
||||||
seed2: number,
|
|
||||||
wobble: number,
|
|
||||||
detail: number,
|
|
||||||
rgb: string,
|
|
||||||
opacity: number,
|
|
||||||
): void {
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i <= detail; i++) {
|
|
||||||
const angle = (i / detail) * Math.PI * 2;
|
|
||||||
const noise = Math.sin(angle * 3 + seed1) * 0.3 + Math.cos(angle * 5 + seed2) * 0.2;
|
|
||||||
const r = Math.max(0.5, baseR * (1 + noise * wobble));
|
|
||||||
const x = cx + Math.cos(angle) * r;
|
|
||||||
const y = cy + Math.sin(angle) * r;
|
|
||||||
if (i === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = `rgba(${rgb}, ${opacity})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawInkLayers(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
p: Particle,
|
|
||||||
config: ResponsiveConfig,
|
|
||||||
): void {
|
|
||||||
const tone = TONES[p.toneIndex];
|
|
||||||
if (!tone) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAccent = ACCENT_TONES.includes(p.toneIndex);
|
|
||||||
const deepRgb = isAccent ? tone.rgb : (p.toneIndex <= 1 ? tone.rgb : TONES[0].rgb);
|
|
||||||
const lightRgb = isAccent ? TONES[1].rgb : (p.toneIndex >= 2 ? tone.rgb : TONES[2].rgb);
|
|
||||||
|
|
||||||
const r = Math.max(0.5, p.radius) * config.inkRadiusScale;
|
|
||||||
const op = p.opacity;
|
|
||||||
const detail = config.wobbleDetail;
|
|
||||||
|
|
||||||
const layers: Array<{ radiusMul: number; opacityMul: number; rgb: string; wobble: number; useRect: boolean }> = [
|
|
||||||
{ radiusMul: 5, opacityMul: 0.04, rgb: lightRgb, wobble: 0.25, useRect: true },
|
|
||||||
{ radiusMul: 3, opacityMul: 0.12, rgb: lightRgb, wobble: 0.2, useRect: true },
|
|
||||||
{ radiusMul: 1.5, opacityMul: 0.4, rgb: deepRgb, wobble: 0.12, useRect: false },
|
|
||||||
{ radiusMul: 1, opacityMul: 0.9, rgb: deepRgb, wobble: 0.06, useRect: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const layerCount = p.inkLayerCount;
|
|
||||||
const startIdx = layers.length - layerCount;
|
|
||||||
|
|
||||||
for (let i = startIdx; i < layers.length; i++) {
|
|
||||||
const layer = layers[i];
|
|
||||||
if (!layer) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const layerR = r * layer.radiusMul;
|
|
||||||
const layerOp = op * layer.opacityMul;
|
|
||||||
|
|
||||||
if (layer.useRect) {
|
|
||||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, layerR);
|
|
||||||
grad.addColorStop(0, `rgba(${layer.rgb}, ${layerOp})`);
|
|
||||||
grad.addColorStop(0.5, `rgba(${layer.rgb}, ${layerOp * 0.3})`);
|
|
||||||
grad.addColorStop(1, 'transparent');
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fillRect(p.x - layerR, p.y - layerR, layerR * 2, layerR * 2);
|
|
||||||
} else {
|
|
||||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, layerR);
|
|
||||||
grad.addColorStop(0, `rgba(${layer.rgb}, ${layerOp})`);
|
|
||||||
grad.addColorStop(0.6, `rgba(${layer.rgb}, ${layerOp * 0.5})`);
|
|
||||||
grad.addColorStop(1, 'transparent');
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, layerR, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
drawInkBlob(ctx, p.x, p.y, layerR * 0.7, p.seed1, p.seed2, layer.wobble, detail, layer.rgb, layerOp * 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawFeibai(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
p: Particle,
|
|
||||||
rgb: string,
|
|
||||||
): void {
|
|
||||||
const speed = Math.sqrt(p.prevVx * p.prevVx + p.prevVy * p.prevVy);
|
|
||||||
if (speed < 0.1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirX = -p.prevVx / speed;
|
|
||||||
const dirY = -p.prevVy / speed;
|
|
||||||
const length = p.radius * (3 + Math.random() * 2);
|
|
||||||
const endX = p.x + dirX * length;
|
|
||||||
const endY = p.y + dirY * length;
|
|
||||||
|
|
||||||
const perpX = -dirY;
|
|
||||||
const perpY = dirX;
|
|
||||||
const curvature = (Math.random() - 0.5) * length * 0.15;
|
|
||||||
const cpX = (p.x + endX) / 2 + perpX * curvature;
|
|
||||||
const cpY = (p.y + endY) / 2 + perpY * curvature;
|
|
||||||
|
|
||||||
const grad = ctx.createLinearGradient(p.x, p.y, endX, endY);
|
|
||||||
grad.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.25})`);
|
|
||||||
grad.addColorStop(1, 'transparent');
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(p.x, p.y);
|
|
||||||
ctx.quadraticCurveTo(cpX, cpY, endX, endY);
|
|
||||||
ctx.strokeStyle = grad;
|
|
||||||
ctx.lineWidth = Math.max(0.3, p.radius * 0.6);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawParticle(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
p: Particle,
|
|
||||||
time: number,
|
|
||||||
config: ResponsiveConfig,
|
|
||||||
): void {
|
|
||||||
if (p.age < p.delay || p.opacity <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tone = TONES[p.toneIndex];
|
|
||||||
if (!tone) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rgb = tone.rgb;
|
|
||||||
const r = Math.max(0.5, p.radius);
|
|
||||||
const gs = config.glowScale;
|
|
||||||
|
|
||||||
if (p.phase === 'spreading') {
|
|
||||||
drawTrail(ctx, p, rgb);
|
|
||||||
drawInkLayers(ctx, p, config);
|
|
||||||
} else if (p.phase === 'settling') {
|
|
||||||
drawInkLayers(ctx, p, config);
|
|
||||||
if (config.showFeibai) {
|
|
||||||
drawFeibai(ctx, p, rgb);
|
|
||||||
}
|
|
||||||
} else if (p.phase === 'fading') {
|
|
||||||
const grad1 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 1.5);
|
|
||||||
grad1.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.6})`);
|
|
||||||
grad1.addColorStop(1, 'transparent');
|
|
||||||
ctx.fillStyle = grad1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
} else {
|
|
||||||
const pulse = 1 + Math.sin(time * 0.002 + p.targetX * 0.01) * 0.15;
|
|
||||||
const glowR = r * 3 * pulse * gs;
|
|
||||||
|
|
||||||
const grad2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
|
||||||
grad2.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.12})`);
|
|
||||||
grad2.addColorStop(0.4, `rgba(${rgb}, ${p.opacity * 0.04})`);
|
|
||||||
grad2.addColorStop(1, 'transparent');
|
|
||||||
ctx.fillStyle = grad2;
|
|
||||||
ctx.fillRect(p.x - glowR, p.y - glowR, glowR * 2, glowR * 2);
|
|
||||||
|
|
||||||
drawInkDot(ctx, p.x, p.y, r, p.rotation, p.scaleX, p.scaleY, rgb, p.opacity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawInkStrings(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
particles: Particle[],
|
|
||||||
config: ResponsiveConfig,
|
|
||||||
): void {
|
|
||||||
const spreading = particles.filter(
|
|
||||||
p => p.phase === 'spreading' && p.age >= p.delay
|
|
||||||
);
|
|
||||||
if (spreading.length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const threshold = config.connectionDistance * config.inkStringThreshold;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
for (let i = 0; i < spreading.length; i++) {
|
|
||||||
const a = spreading[i];
|
|
||||||
if (!a) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (let j = i + 1; j < spreading.length; j++) {
|
|
||||||
const b = spreading[j];
|
|
||||||
if (!b) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const dx = a.x - b.x;
|
|
||||||
const dy = a.y - b.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
if (dist < threshold && dist > 0) {
|
|
||||||
const distRatio = 1 - dist / threshold;
|
|
||||||
const alpha = distRatio * 0.15 * Math.min(a.opacity, b.opacity);
|
|
||||||
|
|
||||||
const useAccent = ACCENT_TONES.includes(a.toneIndex) || ACCENT_TONES.includes(b.toneIndex);
|
|
||||||
const rgb = useAccent ? TONES[3].rgb : TONES[0].rgb;
|
|
||||||
|
|
||||||
const mx = (a.x + b.x) / 2;
|
|
||||||
const my = (a.y + b.y) / 2;
|
|
||||||
const perpX = -dy / dist;
|
|
||||||
const perpY = dx / dist;
|
|
||||||
const curvature = (Math.random() - 0.5) * dist * 0.2;
|
|
||||||
const cpx = mx + perpX * curvature;
|
|
||||||
const cpy = my + perpY * curvature;
|
|
||||||
|
|
||||||
const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y);
|
|
||||||
grad.addColorStop(0, `rgba(${rgb}, ${alpha * 0.3})`);
|
|
||||||
grad.addColorStop(0.5, `rgba(${rgb}, ${alpha})`);
|
|
||||||
grad.addColorStop(1, `rgba(${rgb}, ${alpha * 0.3})`);
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(a.x, a.y);
|
|
||||||
ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
|
|
||||||
ctx.strokeStyle = grad;
|
|
||||||
ctx.lineWidth = Math.max(0.2, distRatio * 1.5);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawConnections(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
particles: Particle[],
|
|
||||||
connectionDistance: number,
|
|
||||||
): void {
|
|
||||||
const morphed = particles.filter(
|
|
||||||
p => p.phase === 'morphing' && p.morphProgress > 0.3
|
|
||||||
);
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
for (let i = 0; i < morphed.length; i++) {
|
|
||||||
const a = morphed[i];
|
|
||||||
if (!a) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const toneA = TONES[a.toneIndex];
|
|
||||||
if (!toneA) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (let j = i + 1; j < morphed.length; j++) {
|
|
||||||
const b = morphed[j];
|
|
||||||
if (!b) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const toneB = TONES[b.toneIndex];
|
|
||||||
if (!toneB) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const dx = a.x - b.x;
|
|
||||||
const dy = a.y - b.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
if (dist < connectionDistance && dist > 0) {
|
|
||||||
const progress = Math.min(a.morphProgress, b.morphProgress);
|
|
||||||
const distRatio = 1 - dist / connectionDistance;
|
|
||||||
const alpha = distRatio * 0.12 * progress;
|
|
||||||
const lineWidth = 0.3 + distRatio * 0.7;
|
|
||||||
|
|
||||||
const useAccent = ACCENT_TONES.includes(a.toneIndex) || ACCENT_TONES.includes(b.toneIndex);
|
|
||||||
const connRgb = useAccent ? TONES[3].rgb : TONES[0].rgb;
|
|
||||||
|
|
||||||
const mx = (a.x + b.x) / 2;
|
|
||||||
const my = (a.y + b.y) / 2;
|
|
||||||
const offset = dist * 0.1;
|
|
||||||
const cpx = mx + (dy / dist) * offset;
|
|
||||||
const cpy = my - (dx / dist) * offset;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(a.x, a.y);
|
|
||||||
ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
|
|
||||||
ctx.strokeStyle = `rgba(${connRgb}, ${alpha})`;
|
|
||||||
ctx.lineWidth = lineWidth;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawBgOrbs(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
W: number,
|
|
||||||
H: number,
|
|
||||||
orbScale: number,
|
|
||||||
): void {
|
|
||||||
const orbs = [
|
|
||||||
{ x: W * 0.72, y: H * 0.28, r: 350 * orbScale, rgb: TONES[3].rgb, alpha: 0.04 },
|
|
||||||
{ x: W * 0.28, y: H * 0.68, r: 280 * orbScale, rgb: TONES[0].rgb, alpha: 0.03 },
|
|
||||||
{ x: W * 0.5, y: H * 0.45, r: 400 * orbScale, rgb: TONES[4].rgb, alpha: 0.015 },
|
|
||||||
{ x: W * 0.85, y: H * 0.7, r: 200 * orbScale, rgb: TONES[1].rgb, alpha: 0.02 },
|
|
||||||
];
|
|
||||||
for (const o of orbs) {
|
|
||||||
const grad = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, o.r);
|
|
||||||
grad.addColorStop(0, `rgba(${o.rgb}, ${o.alpha})`);
|
|
||||||
grad.addColorStop(0.6, `rgba(${o.rgb}, ${o.alpha * 0.3})`);
|
|
||||||
grad.addColorStop(1, 'transparent');
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fillRect(o.x - o.r, o.y - o.r, o.r * 2, o.r * 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParticle(
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
W: number,
|
|
||||||
H: number,
|
|
||||||
delay: number,
|
|
||||||
config: ResponsiveConfig,
|
|
||||||
): Particle {
|
|
||||||
const angle = Math.random() * Math.PI * 2;
|
|
||||||
const dist = Math.random() * 8;
|
|
||||||
const isSplash = Math.random() < config.splashRatio;
|
|
||||||
const speed = isSplash
|
|
||||||
? 1.5 + Math.random() * 3
|
|
||||||
: 0.4 + Math.random() * 1.8;
|
|
||||||
|
|
||||||
const toneIndex = pickToneIndex(isSplash);
|
|
||||||
const isHeavy = toneIndex === 0 || toneIndex === 3;
|
|
||||||
|
|
||||||
const initialRadius = isSplash
|
|
||||||
? 0.5 + Math.random() * 1.5
|
|
||||||
: 2 + Math.random() * 5;
|
|
||||||
|
|
||||||
const [txMin, txMax] = config.targetXRange;
|
|
||||||
const [tyMin, tyMax] = config.targetYRange;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: cx + Math.cos(angle) * dist,
|
|
||||||
y: cy + Math.sin(angle) * dist,
|
|
||||||
vx: Math.cos(angle) * speed,
|
|
||||||
vy: Math.sin(angle) * speed,
|
|
||||||
radius: initialRadius,
|
|
||||||
initialRadius,
|
|
||||||
dataRadius: isSplash ? 0 : 1.2 + Math.random() * 2.5,
|
|
||||||
opacity: isSplash ? 0.6 + Math.random() * 0.3 : 0.5 + Math.random() * 0.4,
|
|
||||||
toneIndex,
|
|
||||||
rotation: Math.random() * Math.PI * 2,
|
|
||||||
scaleX: isHeavy ? 0.8 + Math.random() * 0.2 : 0.5 + Math.random() * 0.4,
|
|
||||||
scaleY: isHeavy ? 0.8 + Math.random() * 0.2 : 0.5 + Math.random() * 0.4,
|
|
||||||
isSplash,
|
|
||||||
phase: 'spreading',
|
|
||||||
spreadTime: 0,
|
|
||||||
maxSpreadTime: isSplash ? 30 + Math.random() * 40 : 70 + Math.random() * 90,
|
|
||||||
settleTime: 0,
|
|
||||||
morphProgress: 0,
|
|
||||||
targetX: W * (txMin + Math.random() * (txMax - txMin)),
|
|
||||||
targetY: H * (tyMin + Math.random() * (tyMax - tyMin)),
|
|
||||||
delay,
|
|
||||||
age: 0,
|
|
||||||
trail: [],
|
|
||||||
seed1: Math.random() * Math.PI * 2,
|
|
||||||
seed2: Math.random() * Math.PI * 2,
|
|
||||||
wobbleFactor: 0.3,
|
|
||||||
prevVx: 0,
|
|
||||||
prevVy: 0,
|
|
||||||
inkLayerCount: config.inkLayers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function initParticlesArray(W: number, H: number, config: ResponsiveConfig): Particle[] {
|
|
||||||
const particles: Particle[] = [];
|
|
||||||
let remaining = config.particleCount;
|
|
||||||
|
|
||||||
for (let c = 0; c < config.centers.length; c++) {
|
|
||||||
const centerDef = config.centers[c];
|
|
||||||
if (!centerDef) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const cx = W * centerDef.x;
|
|
||||||
const cy = H * centerDef.y;
|
|
||||||
const dist = config.centerDistribution[c] ?? 0;
|
|
||||||
const count = c === config.centers.length - 1
|
|
||||||
? remaining
|
|
||||||
: Math.floor(config.particleCount * dist);
|
|
||||||
remaining -= count;
|
|
||||||
|
|
||||||
const baseDelay = c * 25;
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
particles.push(
|
|
||||||
createParticle(cx, cy, W, H, baseDelay + Math.random() * 20, config)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return particles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InkDataMorph({
|
|
||||||
className = '',
|
|
||||||
}: InkDataMorphProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
|
||||||
const animationRef = useRef<number | undefined>(undefined);
|
|
||||||
const startTimeRef = useRef<number>(0);
|
|
||||||
const configRef = useRef<ResponsiveConfig | null>(null);
|
|
||||||
const paperTextureRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const drawStaticFinal = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const config = configRef.current;
|
|
||||||
if (!config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dpr = canvas.width / (canvas.clientWidth || 1);
|
|
||||||
const W = canvas.width / dpr;
|
|
||||||
const H = canvas.height / dpr;
|
|
||||||
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
ctx.fillStyle = '#FAFAF5';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
drawBgOrbs(ctx, W, H, config.bgOrbScale);
|
|
||||||
|
|
||||||
const finalParticles = particlesRef.current
|
|
||||||
.filter(p => !p.isSplash)
|
|
||||||
.map(p => ({
|
|
||||||
...p,
|
|
||||||
x: p.targetX,
|
|
||||||
y: p.targetY,
|
|
||||||
radius: p.dataRadius,
|
|
||||||
opacity: 0.35,
|
|
||||||
phase: 'complete' as const,
|
|
||||||
trail: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (const p of finalParticles) {
|
|
||||||
drawParticle(ctx, p, 0, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawConnections(ctx, finalParticles, config.connectionDistance);
|
|
||||||
drawInkStrings(ctx, finalParticles, config);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (!parent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initCanvas = () => {
|
|
||||||
if (animationRef.current !== undefined) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
animationRef.current = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const W = parent.clientWidth;
|
|
||||||
const H = parent.clientHeight;
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
canvas.width = W * dpr;
|
|
||||||
canvas.height = H * dpr;
|
|
||||||
|
|
||||||
const config = getResponsiveConfig(W);
|
|
||||||
configRef.current = config;
|
|
||||||
particlesRef.current = initParticlesArray(W, H, config);
|
|
||||||
paperTextureRef.current = generatePaperTexture(config.paperTextureSize);
|
|
||||||
|
|
||||||
if (shouldReduceMotion) {
|
|
||||||
drawStaticFinal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startTimeRef.current = performance.now();
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const time = performance.now() - startTimeRef.current;
|
|
||||||
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
ctx.fillStyle = '#FAFAF5';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
drawBgOrbs(ctx, W, H, config.bgOrbScale);
|
|
||||||
|
|
||||||
const particles = particlesRef.current;
|
|
||||||
let allComplete = true;
|
|
||||||
|
|
||||||
for (const p of particles) {
|
|
||||||
updateParticle(p, config.maxTrail);
|
|
||||||
drawParticle(ctx, p, time, config);
|
|
||||||
if (p.phase !== 'complete') {
|
|
||||||
allComplete = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawConnections(ctx, particles, config.connectionDistance);
|
|
||||||
drawInkStrings(ctx, particles, config);
|
|
||||||
|
|
||||||
if (paperTextureRef.current) {
|
|
||||||
drawPaperTexture(ctx, W, H, paperTextureRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allComplete) {
|
|
||||||
animationRef.current = undefined;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
initCanvas();
|
|
||||||
|
|
||||||
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const handleResize = () => {
|
|
||||||
if (resizeTimer !== undefined) {
|
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
}
|
|
||||||
resizeTimer = setTimeout(initCanvas, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
if (resizeTimer !== undefined) {
|
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
}
|
|
||||||
if (animationRef.current !== undefined) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [shouldReduceMotion, drawStaticFinal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className={`absolute inset-0 w-full h-full pointer-events-none ${className}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface InkTechFusionProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: 'subtle' | 'prominent' | 'dynamic';
|
|
||||||
primaryColor?: string;
|
|
||||||
secondaryColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InkBlob {
|
|
||||||
id: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
size: number;
|
|
||||||
opacity: number;
|
|
||||||
duration: number;
|
|
||||||
delay: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InkTechFusion({
|
|
||||||
className = '',
|
|
||||||
variant = 'subtle',
|
|
||||||
primaryColor = '#C41E3A',
|
|
||||||
secondaryColor = '#1C1C1C',
|
|
||||||
}: InkTechFusionProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const [blobs, setBlobs] = useState<InkBlob[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const count = variant === 'prominent' ? 8 : variant === 'dynamic' ? 12 : 5;
|
|
||||||
const generated: InkBlob[] = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
size: Math.random() * 300 + 100,
|
|
||||||
opacity: Math.random() * 0.06 + 0.02,
|
|
||||||
duration: Math.random() * 25 + 20,
|
|
||||||
delay: Math.random() * 5,
|
|
||||||
color: i % 2 === 0 ? primaryColor : secondaryColor,
|
|
||||||
}));
|
|
||||||
setBlobs(generated);
|
|
||||||
}, [variant, primaryColor, secondaryColor]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
|
|
||||||
{blobs.map((blob) => (
|
|
||||||
<motion.div
|
|
||||||
key={blob.id}
|
|
||||||
className="absolute"
|
|
||||||
style={{
|
|
||||||
left: `${blob.x}%`,
|
|
||||||
top: `${blob.y}%`,
|
|
||||||
width: blob.size,
|
|
||||||
height: blob.size,
|
|
||||||
background: `radial-gradient(circle, ${blob.color}${Math.round(blob.opacity * 255).toString(16).padStart(2, '0')} 0%, transparent 70%)`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
filter: 'blur(40px)',
|
|
||||||
willChange: prefersReducedMotion ? 'auto' : 'transform',
|
|
||||||
}}
|
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? { scale: 1, opacity: blob.opacity }
|
|
||||||
: {
|
|
||||||
scale: [0.8, 1.2, 0.9, 1.1, 0.8],
|
|
||||||
opacity: [0, blob.opacity, blob.opacity * 1.2, blob.opacity, 0],
|
|
||||||
x: [0, 30, -20, 10, 0],
|
|
||||||
y: [0, -20, 30, -10, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: blob.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: blob.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<svg className="absolute inset-0 w-full h-full opacity-10">
|
|
||||||
<defs>
|
|
||||||
<filter id="ink-blur">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
|
|
||||||
</filter>
|
|
||||||
<pattern id="ink-texture" width="100" height="100" patternUnits="userSpaceOnUse">
|
|
||||||
<circle cx="50" cy="50" r="1" fill={primaryColor} opacity="0.2" />
|
|
||||||
<circle cx="25" cy="75" r="0.5" fill={secondaryColor} opacity="0.15" />
|
|
||||||
<circle cx="75" cy="25" r="0.8" fill={primaryColor} opacity="0.18" />
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" fill="url(#ink-texture)" filter="url(#ink-blur)" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg className="absolute inset-0 w-full h-full opacity-5">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="tech-line-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor={primaryColor} stopOpacity="0" />
|
|
||||||
<stop offset="50%" stopColor={primaryColor} stopOpacity="0.3" />
|
|
||||||
<stop offset="100%" stopColor={primaryColor} stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<motion.line
|
|
||||||
x1="0%"
|
|
||||||
y1="30%"
|
|
||||||
x2="100%"
|
|
||||||
y2="70%"
|
|
||||||
stroke="url(#tech-line-gradient)"
|
|
||||||
strokeWidth="1"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={prefersReducedMotion ? { pathLength: 1 } : { pathLength: [0, 1, 1, 0] }}
|
|
||||||
transition={{ duration: 15, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
/>
|
|
||||||
<motion.line
|
|
||||||
x1="0%"
|
|
||||||
y1="70%"
|
|
||||||
x2="100%"
|
|
||||||
y2="30%"
|
|
||||||
stroke="url(#tech-line-gradient)"
|
|
||||||
strokeWidth="1"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={prefersReducedMotion ? { pathLength: 1 } : { pathLength: [0, 1, 1, 0] }}
|
|
||||||
transition={{ duration: 18, repeat: Infinity, ease: 'easeInOut', delay: 3 }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/50" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InkTechFusion;
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface MeshGradientProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: 'default' | 'warm' | 'cool' | 'elegant';
|
|
||||||
}
|
|
||||||
|
|
||||||
const gradientVariants = {
|
|
||||||
default: {
|
|
||||||
colors: [
|
|
||||||
'radial-gradient(at 40% 20%, hsla(280,80%,90%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 0%, hsla(189,100%,56%,0.2) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 50%, hsla(355,100%,93%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 50%, hsla(340,100%,76%,0.2) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 100%, hsla(22,100%,77%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 100%, hsla(242,100%,70%,0.2) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 0%, hsla(343,100%,76%,0.2) 0px, transparent 50%)',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
warm: {
|
|
||||||
colors: [
|
|
||||||
'radial-gradient(at 40% 20%, hsla(15,90%,85%,0.4) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 0%, hsla(30,100%,80%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 50%, hsla(0,100%,94%,0.4) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 50%, hsla(20,100%,85%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 100%, hsla(10,100%,90%,0.4) 0px, transparent 50%)',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
cool: {
|
|
||||||
colors: [
|
|
||||||
'radial-gradient(at 40% 20%, hsla(200,80%,90%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 0%, hsla(220,100%,85%,0.2) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 50%, hsla(180,100%,90%,0.3) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 50%, hsla(240,80%,90%,0.2) 0px, transparent 50%)',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
elegant: {
|
|
||||||
colors: [
|
|
||||||
'radial-gradient(at 40% 20%, hsla(0,70%,90%,0.25) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 0%, hsla(0,60%,95%,0.2) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 0% 50%, hsla(350,80%,92%,0.25) 0px, transparent 50%)',
|
|
||||||
'radial-gradient(at 80% 50%, hsla(0,50%,97%,0.2) 0px, transparent 50%)',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MeshGradient({ className = '', variant = 'default' }: MeshGradientProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const { colors } = gradientVariants[variant];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 overflow-hidden ${className}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{colors.map((gradient, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background: gradient,
|
|
||||||
willChange: prefersReducedMotion ? 'auto' : 'transform, opacity',
|
|
||||||
}}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
scale: [1, 1.1, 1],
|
|
||||||
opacity: [0.6, 0.8, 0.6],
|
|
||||||
x: [0, 10, 0],
|
|
||||||
y: [0, -10, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: 20 + index * 2,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: index * 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/30" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MeshGradient;
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
interface InteractiveParticle {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
originX: number;
|
|
||||||
originY: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
size: number;
|
|
||||||
opacity: number;
|
|
||||||
color: string;
|
|
||||||
life: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MouseInteractiveParticlesProps {
|
|
||||||
particleCount?: number;
|
|
||||||
className?: string;
|
|
||||||
colorScheme?: 'red' | 'dark' | 'mixed';
|
|
||||||
interactionRadius?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MouseInteractiveParticles({
|
|
||||||
particleCount = 80,
|
|
||||||
className = '',
|
|
||||||
colorScheme = 'mixed',
|
|
||||||
interactionRadius = 150,
|
|
||||||
}: MouseInteractiveParticlesProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
const mouseRef = useRef({ x: -1000, y: -1000, active: false });
|
|
||||||
const particlesRef = useRef<InteractiveParticle[]>([]);
|
|
||||||
const animationRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const getColors = useCallback(() => {
|
|
||||||
switch (colorScheme) {
|
|
||||||
case 'red':
|
|
||||||
return ['#C41E3A', '#E04A68', '#A01830'];
|
|
||||||
case 'dark':
|
|
||||||
return ['#1C1C1C', '#2D2D2D', '#3E3E3E'];
|
|
||||||
case 'mixed':
|
|
||||||
default:
|
|
||||||
return ['#C41E3A', '#1C1C1C', '#D4A574'];
|
|
||||||
}
|
|
||||||
}, [colorScheme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMounted) {return;}
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {return;}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {return;}
|
|
||||||
|
|
||||||
let width = window.innerWidth;
|
|
||||||
let height = window.innerHeight;
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
width = window.innerWidth;
|
|
||||||
height = window.innerHeight;
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
initParticles();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initParticles = () => {
|
|
||||||
const colors = getColors();
|
|
||||||
particlesRef.current = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < particleCount; i++) {
|
|
||||||
const x = Math.random() * width;
|
|
||||||
const y = Math.random() * height;
|
|
||||||
|
|
||||||
particlesRef.current.push({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
originX: x,
|
|
||||||
originY: y,
|
|
||||||
vx: (Math.random() - 0.5) * 0.5,
|
|
||||||
vy: (Math.random() - 0.5) * 0.5,
|
|
||||||
size: Math.random() * 3 + 1,
|
|
||||||
opacity: Math.random() * 0.5 + 0.2,
|
|
||||||
color: colors[Math.floor(Math.random() * colors.length)] ?? '#C41E3A',
|
|
||||||
life: Math.random() * Math.PI * 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
mouseRef.current.x = e.clientX;
|
|
||||||
mouseRef.current.y = e.clientY;
|
|
||||||
mouseRef.current.active = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
mouseRef.current.x = -1000;
|
|
||||||
mouseRef.current.y = -1000;
|
|
||||||
mouseRef.current.active = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
particlesRef.current.forEach((particle, i) => {
|
|
||||||
particle.life += 0.02;
|
|
||||||
|
|
||||||
if (mouseRef.current.active) {
|
|
||||||
const dx = mouseRef.current.x - particle.x;
|
|
||||||
const dy = mouseRef.current.y - particle.y;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < interactionRadius) {
|
|
||||||
const force = (interactionRadius - distance) / interactionRadius;
|
|
||||||
const angle = Math.atan2(dy, dx);
|
|
||||||
particle.vx -= Math.cos(angle) * force * 0.5;
|
|
||||||
particle.vy -= Math.sin(angle) * force * 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const returnForce = 0.01;
|
|
||||||
particle.vx += (particle.originX - particle.x) * returnForce;
|
|
||||||
particle.vy += (particle.originY - particle.y) * returnForce;
|
|
||||||
|
|
||||||
particle.vx *= 0.98;
|
|
||||||
particle.vy *= 0.98;
|
|
||||||
particle.x += particle.vx;
|
|
||||||
particle.y += particle.vy;
|
|
||||||
|
|
||||||
particle.x += Math.sin(particle.life + i) * 0.1;
|
|
||||||
particle.y += Math.cos(particle.life * 0.8 + i) * 0.1;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = particle.color;
|
|
||||||
ctx.globalAlpha = particle.opacity;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
particlesRef.current.forEach((otherParticle, j) => {
|
|
||||||
if (i === j) {return;}
|
|
||||||
const dx = particle.x - otherParticle.x;
|
|
||||||
const dy = particle.y - otherParticle.y;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < 100) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(particle.x, particle.y);
|
|
||||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
|
||||||
ctx.strokeStyle = particle.color;
|
|
||||||
ctx.globalAlpha = 0.05 * (1 - distance / 100);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
initParticles();
|
|
||||||
animate();
|
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
|
||||||
window.addEventListener('mouseleave', handleMouseLeave);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
window.removeEventListener('mouseleave', handleMouseLeave);
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isMounted, particleCount, getColors, interactionRadius]);
|
|
||||||
|
|
||||||
if (!isMounted) {return null;}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className={`absolute inset-0 pointer-events-none ${className}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MouseInteractiveParticles;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface ParallaxEffectProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
sensitivity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ParallaxEffect({
|
|
||||||
className = '',
|
|
||||||
color = '#C41E3A',
|
|
||||||
sensitivity = 0.05
|
|
||||||
}: ParallaxEffectProps) {
|
|
||||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!containerRef.current) {return;}
|
|
||||||
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
const centerX = rect.width / 2;
|
|
||||||
const centerY = rect.height / 2;
|
|
||||||
|
|
||||||
const x = (e.clientX - rect.left - centerX) * sensitivity;
|
|
||||||
const y = (e.clientY - rect.top - centerY) * sensitivity;
|
|
||||||
|
|
||||||
setMousePosition({ x, y });
|
|
||||||
};
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
container?.addEventListener('mousemove', handleMouseMove);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container?.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
};
|
|
||||||
}, [sensitivity]);
|
|
||||||
|
|
||||||
const layers = [
|
|
||||||
{ size: 300, x: 10, y: 15, factor: 1 },
|
|
||||||
{ size: 200, x: 70, y: 20, factor: 1.5 },
|
|
||||||
{ size: 150, x: 60, y: 60, factor: 2 },
|
|
||||||
{ size: 100, x: 15, y: 65, factor: 2.5 }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className={`absolute inset-0 pointer-events-none ${className}`}>
|
|
||||||
{layers.map((layer, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className="absolute rounded-full"
|
|
||||||
style={{
|
|
||||||
width: layer.size,
|
|
||||||
height: layer.size,
|
|
||||||
background: `radial-gradient(circle, ${color}15 0%, transparent 70%)`,
|
|
||||||
left: `${layer.x}%`,
|
|
||||||
top: `${layer.y}%`
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
x: mousePosition.x * layer.factor,
|
|
||||||
y: mousePosition.y * layer.factor
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 50,
|
|
||||||
damping: 30
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ParallaxEffect;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
||||||
import { motion, useMotionValue } from 'framer-motion';
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
size: number;
|
|
||||||
opacity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParticleGalaxyProps {
|
|
||||||
particleCount?: number;
|
|
||||||
connectionDistance?: number;
|
|
||||||
mouseRadius?: number;
|
|
||||||
particleColor?: string;
|
|
||||||
lineColor?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ParticleGalaxy({
|
|
||||||
particleCount = 100,
|
|
||||||
connectionDistance = 150,
|
|
||||||
mouseRadius = 150,
|
|
||||||
particleColor = '196, 30, 58',
|
|
||||||
lineColor = '196, 30, 58',
|
|
||||||
className = ''
|
|
||||||
}: ParticleGalaxyProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
|
||||||
const animationRef = useRef<number | undefined>(undefined);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
const mouseX = useMotionValue(0);
|
|
||||||
const mouseY = useMotionValue(0);
|
|
||||||
const mouseInCanvas = useMotionValue(false);
|
|
||||||
|
|
||||||
const createParticle = useCallback((width: number, height: number): Particle => {
|
|
||||||
return {
|
|
||||||
x: Math.random() * width,
|
|
||||||
y: Math.random() * height,
|
|
||||||
vx: (Math.random() - 0.5) * 0.8,
|
|
||||||
vy: (Math.random() - 0.5) * 0.8,
|
|
||||||
size: Math.random() * 2 + 1,
|
|
||||||
opacity: Math.random() * 0.5 + 0.2
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initParticles = useCallback((width: number, height: number) => {
|
|
||||||
particlesRef.current = Array.from({ length: particleCount }, () =>
|
|
||||||
createParticle(width, height)
|
|
||||||
);
|
|
||||||
}, [particleCount, createParticle]);
|
|
||||||
|
|
||||||
const drawParticles = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
|
||||||
const mx = mouseX.get();
|
|
||||||
const my = mouseY.get();
|
|
||||||
const inCanvas = mouseInCanvas.get();
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
const particles = particlesRef.current;
|
|
||||||
|
|
||||||
particles.forEach((particle, i) => {
|
|
||||||
let { x, y, vx, vy, size, opacity } = particle;
|
|
||||||
|
|
||||||
if (inCanvas) {
|
|
||||||
const dx = x - mx;
|
|
||||||
const dy = y - my;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < mouseRadius) {
|
|
||||||
const force = (mouseRadius - distance) / mouseRadius;
|
|
||||||
const angle = Math.atan2(dy, dx);
|
|
||||||
vx += Math.cos(angle) * force * 0.5;
|
|
||||||
vy += Math.sin(angle) * force * 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
x += vx;
|
|
||||||
y += vy;
|
|
||||||
|
|
||||||
if (x < 0 || x > width) {vx *= -1;}
|
|
||||||
if (y < 0 || y > height) {vy *= -1;}
|
|
||||||
|
|
||||||
x = Math.max(0, Math.min(width, x));
|
|
||||||
y = Math.max(0, Math.min(height, y));
|
|
||||||
|
|
||||||
vx *= 0.99;
|
|
||||||
vy *= 0.99;
|
|
||||||
|
|
||||||
const speed = Math.sqrt(vx * vx + vy * vy);
|
|
||||||
if (speed < 0.1) {
|
|
||||||
vx += (Math.random() - 0.5) * 0.1;
|
|
||||||
vy += (Math.random() - 0.5) * 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
particlesRef.current[i] = { x, y, vx, vy, size, opacity };
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = `rgba(${particleColor}, ${opacity})`;
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < particles.length; i++) {
|
|
||||||
for (let j = i + 1; j < particles.length; j++) {
|
|
||||||
const p1 = particles[i];
|
|
||||||
const p2 = particles[j];
|
|
||||||
if (!p1 || !p2) {continue;}
|
|
||||||
|
|
||||||
const dx = p1.x - p2.x;
|
|
||||||
const dy = p1.y - p2.y;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < connectionDistance) {
|
|
||||||
const opacity = (1 - distance / connectionDistance) * 0.3;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(p1.x, p1.y);
|
|
||||||
ctx.lineTo(p2.x, p2.y);
|
|
||||||
ctx.strokeStyle = `rgba(${lineColor}, ${opacity})`;
|
|
||||||
ctx.lineWidth = 0.5;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mouseX, mouseY, mouseInCanvas, mouseRadius, particleColor, lineColor, connectionDistance]);
|
|
||||||
|
|
||||||
const animate = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {return;}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {return;}
|
|
||||||
|
|
||||||
drawParticles(ctx, canvas.width, canvas.height);
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
}, [drawParticles]);
|
|
||||||
|
|
||||||
const handleResize = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {return;}
|
|
||||||
|
|
||||||
const container = canvas.parentElement;
|
|
||||||
if (!container) {return;}
|
|
||||||
|
|
||||||
canvas.width = container.clientWidth;
|
|
||||||
canvas.height = container.clientHeight;
|
|
||||||
|
|
||||||
initParticles(canvas.width, canvas.height);
|
|
||||||
}, [initParticles]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {return;}
|
|
||||||
|
|
||||||
const container = canvas.parentElement;
|
|
||||||
if (!container) {return;}
|
|
||||||
|
|
||||||
canvas.width = container.clientWidth;
|
|
||||||
canvas.height = container.clientHeight;
|
|
||||||
|
|
||||||
initParticles(canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
setIsVisible(entry.isIntersecting);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(canvas);
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
animate();
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResizeWithDebounce = () => {
|
|
||||||
setTimeout(handleResize, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResizeWithDebounce);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
window.removeEventListener('resize', handleResizeWithDebounce);
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isVisible, animate, initParticles, handleResize]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
animate();
|
|
||||||
} else if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
}
|
|
||||||
}, [isVisible, animate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`absolute inset-0 pointer-events-none ${className}`}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: isVisible ? 1 : 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="w-full h-full"
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
mouseX.set(e.clientX);
|
|
||||||
mouseY.set(e.clientY);
|
|
||||||
mouseInCanvas.set(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
mouseInCanvas.set(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ParticleGalaxy;
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
targetX: number;
|
|
||||||
targetY: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
size: number;
|
|
||||||
opacity: number;
|
|
||||||
color: string;
|
|
||||||
life: number;
|
|
||||||
maxLife: number;
|
|
||||||
stage: 'idle' | 'dispersing' | 'reforming';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SealAnimationEnhancedProps {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
particleCount?: number;
|
|
||||||
colors?: string[];
|
|
||||||
sealText?: string;
|
|
||||||
animationStages?: boolean;
|
|
||||||
onStageChange?: (stage: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SealAnimationEnhanced({
|
|
||||||
width = 300,
|
|
||||||
height = 300,
|
|
||||||
particleCount = 150,
|
|
||||||
colors = ['#C41E3A', '#D4A574', '#8B4513'],
|
|
||||||
sealText: _sealText = '睿新',
|
|
||||||
animationStages = true,
|
|
||||||
onStageChange,
|
|
||||||
className = '',
|
|
||||||
}: SealAnimationEnhancedProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
|
||||||
const animationRef = useRef<number | null>(null);
|
|
||||||
const [_currentStage, setCurrentStage] = useState<'idle' | 'dispersing' | 'reforming'>('idle');
|
|
||||||
const stageTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const createSealShape = useCallback((width: number, height: number) => {
|
|
||||||
const centerX = width / 2;
|
|
||||||
const centerY = height / 2;
|
|
||||||
const sealSize = Math.min(width, height) * 0.35;
|
|
||||||
const particles: { x: number; y: number }[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < particleCount; i++) {
|
|
||||||
const angle = (i / particleCount) * Math.PI * 2;
|
|
||||||
const radius = sealSize * (0.8 + Math.random() * 0.4);
|
|
||||||
particles.push({
|
|
||||||
x: centerX + Math.cos(angle) * radius * (Math.random() > 0.5 ? 1 : 0.8),
|
|
||||||
y: centerY + Math.sin(angle) * radius * (Math.random() > 0.5 ? 1 : 0.8),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return particles;
|
|
||||||
}, [particleCount]);
|
|
||||||
|
|
||||||
const createParticle = useCallback(
|
|
||||||
(x: number, y: number, targetX: number, targetY: number): Particle => {
|
|
||||||
const color = colors[Math.floor(Math.random() * colors.length)] ?? '#C41E3A';
|
|
||||||
const size = 2 + Math.random() * 3;
|
|
||||||
const maxLife = 200 + Math.random() * 100;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
vx: (Math.random() - 0.5) * 2,
|
|
||||||
vy: (Math.random() - 0.5) * 2,
|
|
||||||
size,
|
|
||||||
opacity: 0.6 + Math.random() * 0.4,
|
|
||||||
color,
|
|
||||||
life: 0,
|
|
||||||
maxLife,
|
|
||||||
stage: 'idle',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[colors]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) {return;}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {return;}
|
|
||||||
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
const sealPositions = createSealShape(width, height);
|
|
||||||
particlesRef.current = sealPositions.map((pos) =>
|
|
||||||
createParticle(pos.x, pos.y, pos.x, pos.y)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (animationStages) {
|
|
||||||
stageTimerRef.current = setTimeout(() => {
|
|
||||||
setCurrentStage('dispersing');
|
|
||||||
onStageChange?.('dispersing');
|
|
||||||
|
|
||||||
particlesRef.current.forEach(p => {
|
|
||||||
p.vx = (Math.random() - 0.5) * 4;
|
|
||||||
p.vy = (Math.random() - 0.5) * 4;
|
|
||||||
p.stage = 'dispersing';
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCurrentStage('reforming');
|
|
||||||
onStageChange?.('reforming');
|
|
||||||
|
|
||||||
particlesRef.current.forEach(p => {
|
|
||||||
p.stage = 'reforming';
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCurrentStage('idle');
|
|
||||||
onStageChange?.('idle');
|
|
||||||
}, 3000);
|
|
||||||
}, 2000);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
particlesRef.current.forEach((particle) => {
|
|
||||||
if (particle.stage === 'reforming') {
|
|
||||||
const dx = particle.targetX - particle.x;
|
|
||||||
const dy = particle.targetY - particle.y;
|
|
||||||
particle.vx += dx * 0.02;
|
|
||||||
particle.vy += dy * 0.02;
|
|
||||||
particle.vx *= 0.95;
|
|
||||||
particle.vy *= 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
particle.x += particle.vx;
|
|
||||||
particle.y += particle.vy;
|
|
||||||
particle.life++;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = particle.color;
|
|
||||||
ctx.globalAlpha = particle.opacity;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
animate();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
}
|
|
||||||
if (stageTimerRef.current) {
|
|
||||||
clearTimeout(stageTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [width, height, createSealShape, createParticle, animationStages, onStageChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className={className}
|
|
||||||
style={{ width, height }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface SubtleDotsProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SubtleDots({
|
|
||||||
className = '',
|
|
||||||
color = '#C41E3A',
|
|
||||||
count = 12
|
|
||||||
}: SubtleDotsProps) {
|
|
||||||
const [dots, setDots] = useState<Array<{
|
|
||||||
id: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
size: number;
|
|
||||||
delay: number;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const generatedDots = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: 10 + Math.random() * 80,
|
|
||||||
y: 10 + Math.random() * 80,
|
|
||||||
size: 2 + Math.random() * 3,
|
|
||||||
delay: i * 0.3
|
|
||||||
}));
|
|
||||||
setDots(generatedDots);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
if (dots.length === 0) {
|
|
||||||
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 pointer-events-none ${className}`}>
|
|
||||||
{dots.map((dot) => (
|
|
||||||
<motion.div
|
|
||||||
key={dot.id}
|
|
||||||
className="absolute rounded-full"
|
|
||||||
style={{
|
|
||||||
width: dot.size,
|
|
||||||
height: dot.size,
|
|
||||||
backgroundColor: color,
|
|
||||||
left: `${dot.x}%`,
|
|
||||||
top: `${dot.y}%`
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 0.2, 0.2, 0],
|
|
||||||
scale: [0, 1, 1, 0]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 4,
|
|
||||||
delay: dot.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.3, 0.7, 1]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SubtleDots;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface SubtleParticleProps {
|
|
||||||
count?: number;
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SubtleParticles({
|
|
||||||
count = 20,
|
|
||||||
size = 3,
|
|
||||||
color = '#C41E3A',
|
|
||||||
className = ''
|
|
||||||
}: SubtleParticleProps) {
|
|
||||||
const [particles, setParticles] = useState<Array<{
|
|
||||||
id: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
delay: number;
|
|
||||||
duration: number;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const generatedParticles = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: Math.random() * 100,
|
|
||||||
y: Math.random() * 100,
|
|
||||||
delay: Math.random() * 5,
|
|
||||||
duration: 8 + Math.random() * 4
|
|
||||||
}));
|
|
||||||
setParticles(generatedParticles);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
if (particles.length === 0) {
|
|
||||||
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 pointer-events-none ${className}`}>
|
|
||||||
{particles.map((particle) => (
|
|
||||||
<motion.div
|
|
||||||
key={particle.id}
|
|
||||||
className="absolute rounded-full"
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
backgroundColor: color,
|
|
||||||
left: `${particle.x}%`,
|
|
||||||
top: `${particle.y}%`
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0, 0.3, 0.3, 0],
|
|
||||||
scale: [0, 1, 1, 0],
|
|
||||||
y: [0, -20, -20, 0]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: particle.duration,
|
|
||||||
delay: particle.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
times: [0, 0.3, 0.7, 1]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SubtleParticles;
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface TechGridFlowProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: 'default' | 'dense' | 'sparse';
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GridLine {
|
|
||||||
id: number;
|
|
||||||
x1: number;
|
|
||||||
y1: number;
|
|
||||||
x2: number;
|
|
||||||
y2: number;
|
|
||||||
delay: number;
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TechGridFlow({
|
|
||||||
className = '',
|
|
||||||
variant = 'default',
|
|
||||||
color = '#C41E3A',
|
|
||||||
}: TechGridFlowProps) {
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const [lines, setLines] = useState<GridLine[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const lineCount = variant === 'dense' ? 30 : variant === 'sparse' ? 10 : 20;
|
|
||||||
const generatedLines: GridLine[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < lineCount; i++) {
|
|
||||||
const isHorizontal = Math.random() > 0.5;
|
|
||||||
generatedLines.push({
|
|
||||||
id: i,
|
|
||||||
x1: isHorizontal ? 0 : Math.random() * 100,
|
|
||||||
y1: isHorizontal ? Math.random() * 100 : 0,
|
|
||||||
x2: isHorizontal ? 100 : Math.random() * 100,
|
|
||||||
y2: isHorizontal ? Math.random() * 100 : 100,
|
|
||||||
delay: Math.random() * 5,
|
|
||||||
duration: Math.random() * 10 + 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setLines(generatedLines);
|
|
||||||
}, [variant]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
|
|
||||||
<svg
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gridGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor={color} stopOpacity="0" />
|
|
||||||
<stop offset="50%" stopColor={color} stopOpacity="0.15" />
|
|
||||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="glow">
|
|
||||||
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="coloredBlur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{lines.map((line) => (
|
|
||||||
<motion.line
|
|
||||||
key={line.id}
|
|
||||||
x1={`${line.x1}%`}
|
|
||||||
y1={`${line.y1}%`}
|
|
||||||
x2={`${line.x2}%`}
|
|
||||||
y2={`${line.y2}%`}
|
|
||||||
stroke="url(#gridGradient)"
|
|
||||||
strokeWidth="1"
|
|
||||||
filter="url(#glow)"
|
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
|
||||||
animate={
|
|
||||||
prefersReducedMotion
|
|
||||||
? { pathLength: 1, opacity: 0.3 }
|
|
||||||
: {
|
|
||||||
pathLength: [0, 1, 1, 0],
|
|
||||||
opacity: [0, 0.3, 0.3, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: line.duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: line.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/20" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TechGridFlow;
|
|
||||||
@@ -14,16 +14,16 @@ interface BreadcrumbProps {
|
|||||||
|
|
||||||
export function Breadcrumb({ items }: BreadcrumbProps) {
|
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="breadcrumb" className="flex items-center space-x-1 text-sm text-[#5C5C5C] py-4">
|
<nav aria-label="breadcrumb" className="flex items-center space-x-1 text-sm text-[var(--color-text-placeholder)] py-4">
|
||||||
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
|
<StaticLink href="/" className="flex items-center hover:text-[var(--color-brand-primary)] transition-colors" aria-label="返回首页">
|
||||||
<Home className="w-4 h-4" />
|
<Home className="w-4 h-4" />
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center">
|
||||||
<ChevronRight className="w-4 h-4 text-[#E5E5E5]" />
|
<ChevronRight className="w-4 h-4 text-[var(--color-border-primary)]" />
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="ml-1 hover:text-[#C41E3A] transition-colors"
|
className="ml-1 hover:text-[var(--color-brand-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { PageTransition } from '@/components/ui/page-transition';
|
||||||
|
|
||||||
|
interface ClientLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientLayout({ children }: ClientLayoutProps) {
|
||||||
|
return (
|
||||||
|
<PageTransition>
|
||||||
|
{children}
|
||||||
|
</PageTransition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '@/lib/constants
|
|||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="bg-[#1C1C1C] text-white py-16" data-testid="footer" role="contentinfo">
|
<footer className="bg-[var(--color-footer-bg)] text-white py-16" data-testid="footer" role="contentinfo">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 lg:gap-8">
|
||||||
<div data-testid="card-brand">
|
<div data-testid="card-brand" className="lg:col-span-2">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Image
|
<Image
|
||||||
src="/logo-light.svg"
|
src="/logo-light.svg"
|
||||||
@@ -19,12 +19,12 @@ export function Footer() {
|
|||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#A0A0A0] text-sm leading-relaxed mb-6">
|
<p className="text-[var(--color-footer-text)] text-sm leading-relaxed mb-6">
|
||||||
{COMPANY_INFO.description}
|
{COMPANY_INFO.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="pt-6 border-t border-[#333]">
|
<div className="pt-6 border-t border-[var(--color-footer-border)]">
|
||||||
<p className="text-sm text-[#A0A0A0] mb-3">关注公众号</p>
|
<p className="text-sm text-[var(--color-footer-text)] mb-3">关注公众号</p>
|
||||||
<div className="inline-block p-2 rounded-lg border border-[#333]">
|
<div className="inline-block p-2 rounded-lg border border-[var(--color-footer-border)]">
|
||||||
<Image
|
<Image
|
||||||
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
|
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
|
||||||
alt="微信公众号二维码"
|
alt="微信公众号二维码"
|
||||||
@@ -44,7 +44,7 @@ export function Footer() {
|
|||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="text-[#A0A0A0] hover:text-white transition-colors duration-200 text-sm"
|
className="text-[var(--color-footer-text)] hover:text-white transition-colors duration-200 text-sm"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
@@ -54,26 +54,23 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-testid="card-products">
|
<div data-testid="card-products">
|
||||||
<h3 className="font-semibold text-base mb-5 text-white">产品</h3>
|
<h3 className="font-semibold text-base mb-5 text-white">产品与方案</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{(MEGA_DROPDOWN_DATA.products ?? []).map((item) => (
|
{(MEGA_DROPDOWN_DATA.products ?? []).map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="text-[#A0A0A0] hover:text-white transition-colors duration-200 text-sm"
|
className="text-[var(--color-footer-text)] hover:text-white transition-colors duration-200 text-sm"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
|
||||||
<h3 className="font-semibold text-base mb-5 mt-8 text-white">解决方案</h3>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{(MEGA_DROPDOWN_DATA.solutions ?? []).map((item) => (
|
{(MEGA_DROPDOWN_DATA.solutions ?? []).map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="text-[#A0A0A0] hover:text-white transition-colors duration-200 text-sm"
|
className="text-[var(--color-footer-text)] hover:text-white transition-colors duration-200 text-sm"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
@@ -86,17 +83,17 @@ export function Footer() {
|
|||||||
<h3 className="font-semibold text-base mb-5 text-white">联系方式</h3>
|
<h3 className="font-semibold text-base mb-5 text-white">联系方式</h3>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
<li className="flex items-start gap-3">
|
<li className="flex items-start gap-3">
|
||||||
<MapPin className="w-4 h-4 text-[#C41E3A] mt-0.5 shrink-0" />
|
<MapPin className="w-4 h-4 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||||||
<span className="text-[#A0A0A0] text-sm">{COMPANY_INFO.address}</span>
|
<span className="text-[var(--color-footer-text)] text-sm">{COMPANY_INFO.address}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-3">
|
<li className="flex items-center gap-3">
|
||||||
<Mail className="w-4 h-4 text-[#C41E3A] shrink-0" />
|
<Mail className="w-4 h-4 text-[var(--color-brand-primary)] shrink-0" />
|
||||||
<span className="text-[#A0A0A0] text-sm">{COMPANY_INFO.email}</span>
|
<span className="text-[var(--color-footer-text)] text-sm">{COMPANY_INFO.email}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 pt-6 border-t border-[#333]">
|
<div className="mt-6 pt-6 border-t border-[var(--color-footer-border)]">
|
||||||
<p className="text-sm text-[#A0A0A0] mb-3">企业微信业务咨询</p>
|
<p className="text-sm text-[var(--color-footer-text)] mb-3">企业微信业务咨询</p>
|
||||||
<div className="inline-block p-2 rounded-lg border border-[#333]">
|
<div className="inline-block p-2 rounded-lg border border-[var(--color-footer-border)]">
|
||||||
<Image
|
<Image
|
||||||
src="/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg"
|
src="/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg"
|
||||||
alt="企业微信业务咨询二维码"
|
alt="企业微信业务咨询二维码"
|
||||||
@@ -110,37 +107,37 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#333] mt-12 pt-8 pb-24 md:pb-8">
|
<div className="border-t border-[var(--color-footer-border)] mt-12 pt-8 pb-24 md:pb-8">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<p className="text-[#666] text-sm">
|
<p className="text-[var(--color-footer-text-muted)] text-sm">
|
||||||
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
|
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<StaticLink href="/privacy" className="text-[#666] hover:text-white text-sm transition-colors duration-200">
|
<StaticLink href="/privacy" className="text-[var(--color-footer-text-muted)] hover:text-white text-sm transition-colors duration-200">
|
||||||
隐私政策
|
隐私政策
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
<StaticLink href="/terms" className="text-[#666] hover:text-white text-sm transition-colors duration-200">
|
<StaticLink href="/terms" className="text-[var(--color-footer-text-muted)] hover:text-white text-sm transition-colors duration-200">
|
||||||
服务条款
|
服务条款
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-6 pt-6 border-t border-[#333]">
|
<div className="text-center mt-6 pt-6 border-t border-[var(--color-footer-border)]">
|
||||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs">
|
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs">
|
||||||
<a
|
<a
|
||||||
href="https://beian.miit.gov.cn/"
|
href="https://beian.miit.gov.cn/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#E0E0E0] hover:text-white transition-colors duration-200"
|
className="text-[var(--color-footer-text-link)] hover:text-white transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{COMPANY_INFO.icp}
|
{COMPANY_INFO.icp}
|
||||||
</a>
|
</a>
|
||||||
<span className="hidden sm:inline text-[#999]">|</span>
|
<span className="hidden sm:inline text-[var(--color-footer-text-dim)]">|</span>
|
||||||
<a
|
<a
|
||||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
|
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-[#E0E0E0] hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
|
className="text-[var(--color-footer-text-link)] hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/images/beian-icon.png"
|
src="/images/beian-icon.png"
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { Suspense, useState, useEffect, useCallback } from 'react';
|
|||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X, MessageCircle } from 'lucide-react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
|
import { useTheme } from '@/contexts/theme-context';
|
||||||
import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA, type NavigationItemV2 } from '@/lib/constants';
|
import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA, type NavigationItemV2 } from '@/lib/constants';
|
||||||
import { MegaDropdown } from '@/components/layout/mega-dropdown';
|
import { MegaDropdown } from '@/components/layout/mega-dropdown';
|
||||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||||
@@ -17,6 +19,7 @@ function HeaderContent() {
|
|||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -77,7 +80,7 @@ function HeaderContent() {
|
|||||||
fixed top-0 left-0 right-0 z-50
|
fixed top-0 left-0 right-0 z-50
|
||||||
transition-all duration-300 ease-out
|
transition-all duration-300 ease-out
|
||||||
${isScrolled
|
${isScrolled
|
||||||
? 'bg-white/95 backdrop-blur-xl border-b border-[#E2E8F0] shadow-sm'
|
? 'bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
|
||||||
: 'bg-transparent'
|
: 'bg-transparent'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -90,7 +93,7 @@ function HeaderContent() {
|
|||||||
aria-label="返回首页"
|
aria-label="返回首页"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.svg"
|
src={resolvedTheme === 'dark' ? '/logo-light.svg' : '/logo.svg'}
|
||||||
alt={COMPANY_INFO.name}
|
alt={COMPANY_INFO.name}
|
||||||
width={120}
|
width={120}
|
||||||
height={30}
|
height={30}
|
||||||
@@ -123,8 +126,8 @@ function HeaderContent() {
|
|||||||
relative px-3 py-1.5 text-sm font-medium
|
relative px-3 py-1.5 text-sm font-medium
|
||||||
transition-all duration-300
|
transition-all duration-300
|
||||||
${isActive(item)
|
${isActive(item)
|
||||||
? 'text-[#1C1C1C]'
|
? 'text-[var(--color-text-primary)]'
|
||||||
: 'text-[#3D3D3D] hover:text-[#1C1C1C]'
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
aria-current={isActive(item) ? 'page' : undefined}
|
aria-current={isActive(item) ? 'page' : undefined}
|
||||||
@@ -132,7 +135,7 @@ function HeaderContent() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
<span
|
<span
|
||||||
className={`
|
className={`
|
||||||
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[#C41E3A] rounded-full
|
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[var(--color-brand-primary)] rounded-full
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
${isActive(item)
|
${isActive(item)
|
||||||
? 'opacity-100 scale-x-100'
|
? 'opacity-100 scale-x-100'
|
||||||
@@ -146,16 +149,28 @@ function HeaderContent() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-3">
|
<div className="hidden md:flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
asChild
|
asChild
|
||||||
|
className="hidden lg:flex"
|
||||||
|
>
|
||||||
|
<StaticLink href="/contact" data-testid="consult-button">
|
||||||
|
<MessageCircle className="w-4 h-4 mr-1.5" />
|
||||||
|
咨询
|
||||||
|
</StaticLink>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="lg:hidden"
|
||||||
>
|
>
|
||||||
<StaticLink href="/contact" data-testid="consult-button">立即咨询</StaticLink>
|
<StaticLink href="/contact" data-testid="consult-button">立即咨询</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="md:hidden p-3 -mr-3 text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5] rounded-lg transition-all duration-200 active:scale-95"
|
className="md:hidden p-3 -mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)] rounded-lg transition-all duration-200 active:scale-95"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@@ -190,7 +205,7 @@ function HeaderContent() {
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: '100%' }}
|
exit={{ x: '100%' }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
className="absolute top-16 right-0 bottom-0 left-0 bg-white/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
|
className="absolute top-16 right-0 bottom-0 left-0 bg-[var(--color-bg-primary)]/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="移动端导航"
|
aria-label="移动端导航"
|
||||||
@@ -211,8 +226,8 @@ function HeaderContent() {
|
|||||||
block px-4 py-4 text-base font-medium rounded-lg
|
block px-4 py-4 text-base font-medium rounded-lg
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isActive(item)
|
${isActive(item)
|
||||||
? 'text-[#1C1C1C] bg-[#F5F5F5] border-l-4 border-[#C41E3A]'
|
? 'text-[var(--color-text-primary)] bg-[var(--color-primary-lighter)] border-l-4 border-[var(--color-brand-primary)]'
|
||||||
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
|
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
|
||||||
@@ -221,14 +236,19 @@ function HeaderContent() {
|
|||||||
</StaticLink>
|
</StaticLink>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
<div className="mt-6 px-4 pt-6 border-t border-[#E2E8F0]">
|
<div className="mt-6 px-4 pt-6 border-t border-[var(--color-border-primary)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">外观模式</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
|
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
|
||||||
立即咨询
|
<MessageCircle className="w-4 h-4 mr-2" />
|
||||||
|
咨询专家
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,13 +266,13 @@ function HeaderFallback() {
|
|||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
|
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded" />
|
<div className="h-8 w-8 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<div key={i} className="h-6 w-16 bg-gray-200 animate-pulse rounded mx-1" />
|
<div key={i} className="h-6 w-16 bg-[var(--color-skeleton-bg)] animate-pulse rounded mx-1" />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="h-9 w-20 bg-gray-200 animate-pulse rounded" />
|
<div className="h-9 w-20 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -2,69 +2,42 @@
|
|||||||
|
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import type { MegaDropdownItem } from '@/lib/constants';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface MegaDropdownItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MegaDropdownProps {
|
interface MegaDropdownProps {
|
||||||
label: string;
|
label: string;
|
||||||
items: MegaDropdownItem[];
|
items: MegaDropdownItem[];
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onOpen?: () => void;
|
onOpen: () => void;
|
||||||
onClose?: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOVER_DELAY = 150;
|
|
||||||
|
|
||||||
export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }: MegaDropdownProps) {
|
export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }: MegaDropdownProps) {
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
if (isOpen) { onToggle(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, [isOpen, onToggle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (hoverTimeoutRef.current) {
|
|
||||||
clearTimeout(hoverTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
if (hoverTimeoutRef.current) {
|
clearTimeout(timeoutRef.current);
|
||||||
clearTimeout(hoverTimeoutRef.current);
|
onOpen();
|
||||||
hoverTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (!isOpen) {
|
|
||||||
if (onOpen) {
|
|
||||||
onOpen();
|
|
||||||
} else {
|
|
||||||
onToggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
hoverTimeoutRef.current = setTimeout(() => {
|
timeoutRef.current = setTimeout(onClose, 150);
|
||||||
if (isOpen) {
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
onToggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, HOVER_DELAY);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearTimeout(timeoutRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropdownRef} className="relative" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
<div ref={dropdownRef} className="relative" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
<button
|
<button
|
||||||
@@ -73,8 +46,8 @@ export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }
|
|||||||
flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md
|
flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isOpen
|
${isOpen
|
||||||
? 'text-[#C41E3A] bg-[#FEF2F4]'
|
? 'text-[var(--color-brand-primary)] bg-[var(--color-brand-primary-bg)]'
|
||||||
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@@ -93,19 +66,21 @@ export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
exit={{ opacity: 0, y: -8 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute top-full left-1/2 -translate-x-1/2 mt-3 w-[520px] bg-white rounded-xl border border-[#E5E5E5] shadow-lg p-5 z-50"
|
className="absolute top-full left-1/2 -translate-x-1/2 pt-2 w-[520px] z-50"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] shadow-lg p-5">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<StaticLink
|
<StaticLink
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="block p-4 rounded-lg border-l-[3px] border-l-[#C41E3A] hover:bg-[#FFFBF5] transition-colors duration-200"
|
className="block p-4 rounded-lg border-l-[3px] border-l-[var(--color-brand-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<div className="text-sm font-semibold text-[#1C1C1C]">{item.title}</div>
|
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.title}</div>
|
||||||
<div className="text-xs text-[#5C5C5C] mt-1.5 leading-relaxed">{item.description}</div>
|
<div className="text-xs text-[var(--color-text-placeholder)] mt-1.5 leading-relaxed">{item.description}</div>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,15 +46,15 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
onKeyDown={(e) => handleKeyDown(e)}
|
onKeyDown={(e) => handleKeyDown(e)}
|
||||||
className="p-3 rounded-md hover:bg-[#F5F5F5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
className="p-3 rounded-md hover:bg-[var(--color-primary-lighter)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||||
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-controls="mobile-menu-panel"
|
aria-controls="mobile-menu-panel"
|
||||||
>
|
>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<X className="w-6 h-6 text-[#171717]" />
|
<X className="w-6 h-6 text-[var(--color-text-primary)]" />
|
||||||
) : (
|
) : (
|
||||||
<Menu className="w-6 h-6 text-[#171717]" />
|
<Menu className="w-6 h-6 text-[var(--color-text-primary)]" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
id="mobile-menu-panel"
|
id="mobile-menu-panel"
|
||||||
className="fixed top-16 left-0 right-0 bg-white border-b border-[#E5E5E5] z-50 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto"
|
className="fixed top-16 left-0 right-0 bg-[var(--color-bg-primary)] border-b border-[var(--color-border-primary)] z-50 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="移动端导航"
|
aria-label="移动端导航"
|
||||||
>
|
>
|
||||||
@@ -81,7 +81,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => toggleDropdown(item.dropdownKey!)}
|
onClick={() => toggleDropdown(item.dropdownKey!)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, () => toggleDropdown(item.dropdownKey!))}
|
onKeyDown={(e) => handleKeyDown(e, () => toggleDropdown(item.dropdownKey!))}
|
||||||
className="flex items-center justify-between w-full text-left px-4 py-4 text-[#171717] hover:bg-[#FEF2F4] hover:text-[#C41E3A] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-inset min-h-[48px]"
|
className="flex items-center justify-between w-full text-left px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-primary-bg)] hover:text-[var(--color-brand-primary)] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-inset min-h-[48px]"
|
||||||
aria-expanded={expandedDropdown === item.dropdownKey}
|
aria-expanded={expandedDropdown === item.dropdownKey}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -99,10 +99,10 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
|||||||
<StaticLink
|
<StaticLink
|
||||||
href={sub.href}
|
href={sub.href}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="block px-4 py-3 text-sm text-[#595959] hover:text-[#C41E3A] hover:bg-[#FEF2F4] rounded-md transition-colors"
|
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-bg)] rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-[#1C1C1C]">{sub.title}</span>
|
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
|
||||||
<span className="block text-xs text-[#8C8C8C] mt-0.5">{sub.description}</span>
|
<span className="block text-xs text-[var(--color-text-hint)] mt-0.5">{sub.description}</span>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -113,7 +113,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
|||||||
<StaticLink
|
<StaticLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="block px-4 py-4 text-[#171717] hover:bg-[#FEF2F4] hover:text-[#C41E3A] rounded-md transition-colors min-h-[48px]"
|
className="block px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-primary-bg)] hover:text-[var(--color-brand-primary)] rounded-md transition-colors min-h-[48px]"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('MobileTabBar', () => {
|
|||||||
|
|
||||||
it('should show active indicator', () => {
|
it('should show active indicator', () => {
|
||||||
render(<MobileTabBar />);
|
render(<MobileTabBar />);
|
||||||
const activeIndicator = document.querySelector('.bg-\\[\\#C41E3A\\]');
|
const activeIndicator = document.querySelector('.bg-\\[var\\(--color-brand-primary\\)\\]');
|
||||||
expect(activeIndicator).toBeInTheDocument();
|
expect(activeIndicator).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
|
import { Home, Lightbulb, Package, FileText, User } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'home', label: '首页', href: '/', icon: Home },
|
{ id: 'home', label: '首页', href: '/', icon: Home },
|
||||||
{ id: 'services', label: '服务', href: '/services', icon: Briefcase },
|
|
||||||
{ id: 'products', label: '产品', href: '/products', icon: Package },
|
{ id: 'products', label: '产品', href: '/products', icon: Package },
|
||||||
|
{ id: 'solutions', label: '方案', href: '/solutions', icon: Lightbulb },
|
||||||
{ id: 'about', label: '关于', href: '/about', icon: FileText },
|
{ id: 'about', label: '关于', href: '/about', icon: FileText },
|
||||||
{ id: 'contact', label: '联系', href: '/contact', icon: User },
|
{ id: 'contact', label: '联系', href: '/contact', icon: User },
|
||||||
];
|
];
|
||||||
@@ -27,8 +27,8 @@ export function MobileTabBar() {
|
|||||||
if (id === 'products') {
|
if (id === 'products') {
|
||||||
return pathname === '/products' || pathname.startsWith('/products/');
|
return pathname === '/products' || pathname.startsWith('/products/');
|
||||||
}
|
}
|
||||||
if (id === 'services') {
|
if (id === 'solutions') {
|
||||||
return pathname === '/services' || pathname.startsWith('/services/');
|
return pathname === '/solutions' || pathname.startsWith('/solutions/');
|
||||||
}
|
}
|
||||||
if (id === 'about') {
|
if (id === 'about') {
|
||||||
return pathname === '/about';
|
return pathname === '/about';
|
||||||
@@ -37,7 +37,7 @@ export function MobileTabBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-white/95 backdrop-blur-xl border-t border-[#E5E5E5] safe-area-inset-bottom">
|
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-t border-[var(--color-border-primary)] safe-area-inset-bottom">
|
||||||
<div className="flex items-center justify-around h-16">
|
<div className="flex items-center justify-around h-16">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
@@ -50,24 +50,31 @@ export function MobileTabBar() {
|
|||||||
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-12"
|
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-12"
|
||||||
>
|
>
|
||||||
<div className="relative flex flex-col items-center justify-center py-2">
|
<div className="relative flex flex-col items-center justify-center py-2">
|
||||||
|
{active && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTabTop"
|
||||||
|
className="absolute -top-0 w-6 h-[3px] bg-[var(--color-brand-primary)] rounded-full"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-6 h-6 transition-colors',
|
'w-6 h-6 transition-colors',
|
||||||
active ? 'text-[#C41E3A]' : 'text-[#5C5C5C] group-hover:text-[#1C1C1C]'
|
active ? 'text-[var(--color-brand-primary)]' : 'text-[var(--color-text-placeholder)] group-hover:text-[var(--color-text-primary)]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs mt-1 transition-colors',
|
'text-xs mt-1 transition-colors',
|
||||||
active ? 'text-[#C41E3A] font-medium' : 'text-[#5C5C5C]'
|
active ? 'text-[var(--color-brand-primary)] font-medium' : 'text-[var(--color-text-placeholder)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</span>
|
</span>
|
||||||
{active && (
|
{active && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="activeTab"
|
layoutId="activeTabBottom"
|
||||||
className="absolute -bottom-1 w-8 h-0.5 bg-[#C41E3A] rounded-full"
|
className="absolute -bottom-1 w-8 h-0.5 bg-[var(--color-brand-primary)] rounded-full"
|
||||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,23 +14,23 @@ interface PageNavProps {
|
|||||||
|
|
||||||
export function PageNav({ items }: PageNavProps) {
|
export function PageNav({ items }: PageNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="breadcrumb" className="flex items-center gap-[3px] md:gap-1 text-[11px] md:text-sm text-[#A3A3A3] mb-2 md:mb-8 -ml-0.5">
|
<nav aria-label="breadcrumb" className="flex items-center gap-[3px] md:gap-1 text-[11px] md:text-sm text-[var(--color-text-subtle)] mb-2 md:mb-8 -ml-0.5">
|
||||||
<StaticLink href="/" className="flex items-center w-fit hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
|
<StaticLink href="/" className="flex items-center w-fit hover:text-[var(--color-brand-primary)] transition-colors" aria-label="返回首页">
|
||||||
<Home className="w-2.5 h-2.5 md:w-3.5 md:h-3.5" />
|
<Home className="w-2.5 h-2.5 md:w-3.5 md:h-3.5" />
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const isLast = index === items.length - 1;
|
const isLast = index === items.length - 1;
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-[3px] md:gap-1">
|
<div key={index} className="flex items-center gap-[3px] md:gap-1">
|
||||||
<ChevronRight className="w-2.5 h-2.5 md:w-3.5 md:h-3.5 text-[#D4D4D4]" />
|
<ChevronRight className="w-2.5 h-2.5 md:w-3.5 md:h-3.5 text-[var(--color-border-secondary)]" />
|
||||||
{isLast || !item.href ? (
|
{isLast || !item.href ? (
|
||||||
<span className={isLast ? 'text-[#1C1C1C] font-medium' : ''}>
|
<span className={isLast ? 'text-[var(--color-text-primary)] font-medium' : ''}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<StaticLink
|
<StaticLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="hover:text-[#C41E3A] transition-colors"
|
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { AboutSection } from './about-section';
|
|
||||||
|
|
||||||
jest.mock('framer-motion', () => ({
|
|
||||||
motion: {
|
|
||||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
||||||
},
|
|
||||||
useInView: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('next/link', () => {
|
|
||||||
const MockLink = ({ children, href }: any) => <a href={href}>{children}</a>;
|
|
||||||
MockLink.displayName = 'MockLink';
|
|
||||||
return MockLink;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AboutSection', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render about section', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const section = document.querySelector('section#about');
|
|
||||||
expect(section).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render section heading', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render company slogan', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
expect(screen.getByText(/数字化转型不是一场冒险/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render company mission', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
expect(screen.getByText(/务实/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Statistics', () => {
|
|
||||||
it('should render statistics cards', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const cards = document.querySelectorAll('.text-3xl');
|
|
||||||
expect(cards.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display statistics in grid layout', () => {
|
|
||||||
const { container } = render(<AboutSection />);
|
|
||||||
const grid = container.querySelector('.grid-cols-1');
|
|
||||||
expect(grid).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Call to Action', () => {
|
|
||||||
it('should render value cards with titles', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
expect(screen.getByText('务实')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('共情')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('敏捷')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
|
||||||
it('should have region role', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const section = screen.getByRole('region');
|
|
||||||
expect(section).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have aria-labelledby attribute', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const section = document.querySelector('section#about');
|
|
||||||
expect(section).toHaveAttribute('aria-labelledby', 'about-heading');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have accessible heading', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const heading = screen.getByRole('heading', { level: 2 });
|
|
||||||
expect(heading).toHaveAttribute('id', 'about-heading');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Styling', () => {
|
|
||||||
it('should have background color', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const section = document.querySelector('section#about');
|
|
||||||
expect(section).toHaveClass('bg-[#FAFAFA]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have proper padding', () => {
|
|
||||||
render(<AboutSection />);
|
|
||||||
const section = document.querySelector('section#about');
|
|
||||||
expect(section).toHaveClass('py-20');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have decorative background pattern', () => {
|
|
||||||
const { container } = render(<AboutSection />);
|
|
||||||
const pattern = container.querySelector('.absolute.inset-0');
|
|
||||||
expect(pattern).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
|
||||||
import { Target, Heart, Zap } from 'lucide-react';
|
|
||||||
|
|
||||||
const VALUES = [
|
|
||||||
{
|
|
||||||
icon: Target,
|
|
||||||
title: '务实',
|
|
||||||
description: '不画大饼,只做能落地的方案。每个建议都经过深思熟虑,每个方向都经得起推敲。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Heart,
|
|
||||||
title: '共情',
|
|
||||||
description: '先理解您的困境,再提供解决方案。我们相信,好的技术伙伴首先是好的倾听者。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Zap,
|
|
||||||
title: '敏捷',
|
|
||||||
description: '快速响应,持续迭代。在不确定的市场中,速度本身就是竞争力。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const valueAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
|
|
||||||
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
|
|
||||||
{ rgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
|
|
||||||
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AboutSection() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="about" role="region" aria-labelledby="about-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
|
|
||||||
<div className="container-wide">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
|
||||||
>
|
|
||||||
<h2 id="about-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
|
||||||
关于<span className="text-[#C41E3A] font-calligraphy">睿新致远</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959]">
|
|
||||||
我们相信,数字化转型不是一场冒险,而是一次有准备的远行
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{VALUES.map((item, idx) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const accent = valueAccents[idx % valueAccents.length]!;
|
|
||||||
return (
|
|
||||||
<InkGlowCard
|
|
||||||
key={item.title}
|
|
||||||
index={idx}
|
|
||||||
accentColorRgb={accent.rgb}
|
|
||||||
glowStart={accent.glowStart}
|
|
||||||
glowEnd={accent.glowEnd}
|
|
||||||
>
|
|
||||||
<div className="p-6 md:p-8">
|
|
||||||
<div
|
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center mb-5"
|
|
||||||
style={{ backgroundColor: `rgba(${accent.rgb}, 0.06)` }}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" style={{ color: accent.glowStart }} strokeWidth={1.8} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2">{item.title}</h3>
|
|
||||||
<p className="text-sm text-[#595959] leading-relaxed">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</InkGlowCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChallengeCard } from '@/components/ui/challenge-card';
|
import { ChallengeCard } from '@/components/ui/challenge-card';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
@@ -30,58 +29,48 @@ const CHALLENGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function ChallengeSection() {
|
export function ChallengeSection() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry?.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sectionRef.current) {
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="challenges" className="relative py-20 md:py-28 bg-[var(--color-bg-section)]">
|
||||||
id="challenges"
|
|
||||||
ref={sectionRef}
|
|
||||||
className="py-20 md:py-28 bg-[#FAFAFA]"
|
|
||||||
>
|
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : {}}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-100px' }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
className="mb-14"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
您的挑战,我们的<span className="text-[#C41E3A] font-calligraphy">使命</span>
|
<div className="h-px flex-1 bg-[var(--color-border-primary)]" />
|
||||||
|
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">CHALLENGES</span>
|
||||||
|
<div className="h-px flex-1 bg-[var(--color-border-primary)]" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] text-center">
|
||||||
|
您的挑战,我们的<span className="text-[var(--color-brand-primary)] font-calligraphy">使命</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-[#595959]">
|
<p className="text-base text-[var(--color-text-muted)] text-center mt-4 max-w-xl mx-auto">
|
||||||
深入理解企业数字化进程中的核心痛点,提供针对性解决方案
|
深入理解企业数字化进程中的核心痛点,提供针对性解决方案
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||||
{CHALLENGES.map((challenge, index) => (
|
{CHALLENGES.map((challenge, index) => (
|
||||||
<ChallengeCard
|
<motion.div
|
||||||
key={challenge.id}
|
key={challenge.id}
|
||||||
title={challenge.title}
|
initial={shouldReduceMotion ? {} : { opacity: 0, x: index === 0 ? -32 : index === 1 ? 0 : 32 }}
|
||||||
description={challenge.description}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
scenario={challenge.scenario}
|
viewport={{ once: true, margin: '-80px' }}
|
||||||
href={challenge.href}
|
transition={{ duration: 0.55, delay: index * 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||||
index={index}
|
>
|
||||||
/>
|
<ChallengeCard
|
||||||
|
title={challenge.title}
|
||||||
|
description={challenge.description}
|
||||||
|
scenario={challenge.scenario}
|
||||||
|
href={challenge.href}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Toast } from '@/components/ui/toast';
|
|
||||||
import { useFormAutosave } from '@/hooks/use-form-autosave';
|
|
||||||
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, Save } from 'lucide-react';
|
|
||||||
import { COMPANY_INFO } from '@/lib/constants';
|
|
||||||
|
|
||||||
const contactFormSchema = z.object({
|
|
||||||
name: z.string().min(2, '姓名至少需要2个字符'),
|
|
||||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
|
|
||||||
email: z.string().email('请输入有效的邮箱地址'),
|
|
||||||
message: z.string().min(10, '留言内容至少需要10个字符'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
|
||||||
|
|
||||||
interface FormErrors {
|
|
||||||
name?: string;
|
|
||||||
phone?: string;
|
|
||||||
email?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactSection() {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
const [showToast, setShowToast] = useState(false);
|
|
||||||
const [toastMessage, setToastMessage] = useState('');
|
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: formData,
|
|
||||||
updateData,
|
|
||||||
lastSaved,
|
|
||||||
isRestored,
|
|
||||||
clearSavedData,
|
|
||||||
} = useFormAutosave<ContactFormData>({
|
|
||||||
key: 'contact_form',
|
|
||||||
initialData: {
|
|
||||||
name: '',
|
|
||||||
phone: '',
|
|
||||||
email: '',
|
|
||||||
message: '',
|
|
||||||
},
|
|
||||||
debounceMs: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry?.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sectionRef.current) {
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateField = (field: keyof ContactFormData, value: string) => {
|
|
||||||
try {
|
|
||||||
contactFormSchema.shape[field].parse(value);
|
|
||||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
const fieldError = error.issues[0];
|
|
||||||
if (fieldError) {
|
|
||||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field: keyof ContactFormData, value: string) => {
|
|
||||||
updateData({ [field]: value });
|
|
||||||
if (errors[field]) {
|
|
||||||
validateField(field, value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (field: keyof ContactFormData, value: string) => {
|
|
||||||
validateField(field, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
const result = contactFormSchema.safeParse(formData);
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors: FormErrors = {};
|
|
||||||
result.error.issues.forEach((issue) => {
|
|
||||||
const field = issue.path[0] as keyof ContactFormData;
|
|
||||||
fieldErrors[field] = issue.message;
|
|
||||||
});
|
|
||||||
setErrors(fieldErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://formspree.io/f/' + process.env.NEXT_PUBLIC_FORMSPREE_ID, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
clearSavedData();
|
|
||||||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
|
||||||
setToastType('success');
|
|
||||||
setShowToast(true);
|
|
||||||
} else {
|
|
||||||
setToastMessage('提交失败,请稍后重试。');
|
|
||||||
setToastType('error');
|
|
||||||
setShowToast(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setToastMessage('网络错误,请稍后重试。');
|
|
||||||
setToastType('error');
|
|
||||||
setShowToast(true);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="contact" role="region" aria-labelledby="contact-heading" className="py-20 md:py-28 bg-white" ref={sectionRef}>
|
|
||||||
{showToast && (
|
|
||||||
<Toast
|
|
||||||
message={toastMessage}
|
|
||||||
type={toastType}
|
|
||||||
onClose={() => setShowToast(false)}
|
|
||||||
data-testid="toast-notification"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="container-wide">
|
|
||||||
<div
|
|
||||||
className={`mb-14 opacity-0 translate-y-4 ${isVisible ? 'animate-fade-in-up' : ''}`}
|
|
||||||
>
|
|
||||||
<h2 id="contact-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4 text-center">
|
|
||||||
开启 <span className="text-[#C41E3A] font-calligraphy">合作</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959] max-w-2xl mx-auto text-center">
|
|
||||||
无论您有任何问题或合作意向,我们都很乐意与您交流
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
|
|
||||||
<div
|
|
||||||
className={`lg:col-span-2 space-y-8 flex flex-col opacity-0 translate-y-4 ${isVisible ? 'animate-fade-in-up stagger-1' : ''}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-6">联系方式</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-start gap-4 group">
|
|
||||||
<div className="w-10 h-10 bg-[#C41E3A] rounded-lg flex items-center justify-center shrink-0 transition-transform duration-200 group-hover:scale-105">
|
|
||||||
<Mail className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[#A3A3A3] mb-1">邮箱</p>
|
|
||||||
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[#1C1C1C] hover:text-[#C41E3A] transition-colors duration-200">
|
|
||||||
{COMPANY_INFO.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-4 group">
|
|
||||||
<div className="w-10 h-10 bg-[#C41E3A] rounded-lg flex items-center justify-center shrink-0 transition-transform duration-200 group-hover:scale-105">
|
|
||||||
<MapPin className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[#A3A3A3] mb-1">地址</p>
|
|
||||||
<p className="text-[#1C1C1C]">{COMPANY_INFO.address}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#FAFAFA] p-5 rounded-xl border border-[#F0F0F0]" aria-label="工作时间" data-testid="work-hours-card">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Clock className="w-4 h-4 text-[#C41E3A]" />
|
|
||||||
<h4 className="text-sm font-medium text-[#1C1C1C]">工作时间</h4>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-sm" data-testid="work-hours-row">
|
|
||||||
<span className="text-[#595959]">周一至周五</span>
|
|
||||||
<span className="text-[#C41E3A]">9:00 - 18:00</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#FAFAFA] p-5 rounded-xl border border-[#F0F0F0]">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<HeadphonesIcon className="w-4 h-4 text-[#C41E3A]" />
|
|
||||||
<h4 className="text-sm font-medium text-[#1C1C1C]">我们的承诺</h4>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
|
||||||
<p className="text-sm text-[#595959]">工作日 2 小时内快速响应您的咨询</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
|
||||||
<p className="text-sm text-[#595959]">提供免费的业务咨询和方案评估服务</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
|
||||||
<p className="text-sm text-[#595959]">根据您的需求量身定制最优解决方案</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`lg:col-span-3 flex flex-col opacity-0 translate-y-4 ${isVisible ? 'animate-fade-in-up stagger-2' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="bg-[#FAFAFA] p-6 sm:p-8 rounded-xl border border-[#F0F0F0] flex-1 flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C]">发送消息</h3>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-[#A3A3A3]">
|
|
||||||
{lastSaved && (
|
|
||||||
<>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
<span>已保存 {lastSaved.toLocaleTimeString()}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isRestored && (
|
|
||||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
|
|
||||||
<span className="text-sm text-blue-700">
|
|
||||||
已恢复您上次未提交的表单内容
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearSavedData}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 underline"
|
|
||||||
>
|
|
||||||
清除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSubmitted ? (
|
|
||||||
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
|
|
||||||
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4 animate-stamp-in">
|
|
||||||
<CheckCircle2 className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-xl font-semibold text-[#1C1C1C] mb-2">消息已发送</h4>
|
|
||||||
<p className="text-[#A3A3A3]">感谢您的留言,我们会尽快与您联系!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label="姓名"
|
|
||||||
id="name"
|
|
||||||
placeholder="请输入您的姓名"
|
|
||||||
required
|
|
||||||
data-testid="name-input"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
onBlur={(e) => handleBlur('name', e.target.value)}
|
|
||||||
error={errors.name}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="电话"
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
placeholder="请输入您的电话"
|
|
||||||
required
|
|
||||||
data-testid="phone-input"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={(e) => handleChange('phone', e.target.value)}
|
|
||||||
onBlur={(e) => handleBlur('phone', e.target.value)}
|
|
||||||
error={errors.phone}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label="邮箱"
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="请输入您的邮箱"
|
|
||||||
required
|
|
||||||
data-testid="email-input"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => handleChange('email', e.target.value)}
|
|
||||||
onBlur={(e) => handleBlur('email', e.target.value)}
|
|
||||||
error={errors.email}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label="留言内容"
|
|
||||||
id="message"
|
|
||||||
placeholder="请输入您想咨询的内容"
|
|
||||||
rows={5}
|
|
||||||
required
|
|
||||||
data-testid="message-input"
|
|
||||||
value={formData.message}
|
|
||||||
onChange={(e) => handleChange('message', e.target.value)}
|
|
||||||
onBlur={(e) => handleBlur('message', e.target.value)}
|
|
||||||
error={errors.message}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
className="w-full group mt-auto min-h-13 md:min-h-0"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
data-testid="submit-button"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
发送中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="mr-2 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
|
||||||
发送消息
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { useRef, useState } from 'react';
|
||||||
|
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { COMPANY_INFO } from '@/lib/constants';
|
import { COMPANY_INFO } from '@/lib/constants';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
interface CTASectionProps {
|
interface CTASectionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -18,38 +20,158 @@ interface CTASectionProps {
|
|||||||
export function CTASection({
|
export function CTASection({
|
||||||
title = '开启您的数字化转型之旅',
|
title = '开启您的数字化转型之旅',
|
||||||
description = `与${COMPANY_INFO.shortName}一起,让技术成为您业务增长的核心引擎`,
|
description = `与${COMPANY_INFO.shortName}一起,让技术成为您业务增长的核心引擎`,
|
||||||
primaryLabel = '立即咨询',
|
primaryLabel = '开启数字化转型之旅',
|
||||||
primaryHref = '/contact',
|
primaryHref = '/contact',
|
||||||
secondaryLabel = '了解方案',
|
secondaryLabel = '了解方案',
|
||||||
secondaryHref = '/solutions',
|
secondaryHref = '/solutions',
|
||||||
}: CTASectionProps) {
|
}: CTASectionProps) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mouseX = useMotionValue(0);
|
||||||
|
const mouseY = useMotionValue(0);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const springX = useSpring(mouseX, { stiffness: 80, damping: 20 });
|
||||||
|
const springY = useSpring(mouseY, { stiffness: 80, damping: 20 });
|
||||||
|
|
||||||
|
const glowX = useTransform(springX, [0, 1], ['-10%', '110%']);
|
||||||
|
const glowY = useTransform(springY, [0, 1], ['-10%', '110%']);
|
||||||
|
|
||||||
|
function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!containerRef.current || shouldReduceMotion) {return;}
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
mouseX.set((e.clientX - rect.left) / rect.width);
|
||||||
|
mouseY.set((e.clientY - rect.top) / rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="cta" className="py-20 md:py-28 bg-[#1C1C1C]">
|
<section
|
||||||
<div className="container-wide">
|
id="cta"
|
||||||
|
ref={containerRef}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className="relative py-24 md:py-32 overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'var(--color-cta-bg)' }}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<filter id="cta-grain">
|
||||||
|
<feTurbulence
|
||||||
|
type="fractalNoise"
|
||||||
|
baseFrequency="0.75"
|
||||||
|
numOctaves="4"
|
||||||
|
stitchTiles="stitch"
|
||||||
|
result="noise"
|
||||||
|
/>
|
||||||
|
<feColorMatrix type="saturate" values="0" in="noise" result="desaturated" />
|
||||||
|
<feComponentTransfer in="desaturated" result="faded">
|
||||||
|
<feFuncA type="linear" slope="0.04" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
</filter>
|
||||||
|
<rect width="100%" height="100%" filter="url(#cta-grain)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{!shouldReduceMotion && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-[-20%]"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at center, rgba(196, 30, 58, 0.08) 0%, transparent 50%)',
|
||||||
|
x: glowX,
|
||||||
|
y: glowY,
|
||||||
|
opacity: isHovered ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
transition={{ opacity: { duration: 0.6 } }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.15, 1],
|
||||||
|
opacity: [0.06, 0.12, 0.06],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
className="absolute left-[10%] top-[20%] w-64 h-64 rounded-full"
|
||||||
|
style={{ background: 'radial-gradient(circle, rgba(196,30,58,0.2), transparent 70%)' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.2, 1],
|
||||||
|
opacity: [0.05, 0.1, 0.05],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 10,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
delay: 2,
|
||||||
|
}}
|
||||||
|
className="absolute right-[5%] bottom-[25%] w-80 h-80 rounded-full"
|
||||||
|
style={{ background: 'radial-gradient(circle, rgba(196,30,58,0.15), transparent 70%)' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to top, rgba(196, 30, 58, 0.06) 0%, transparent 40%), linear-gradient(135deg, transparent 40%, rgba(196, 30, 58, 0.03) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23C41E3A' fill-opacity='0.02'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container-wide relative z-10">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0.96, y: 16 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true, margin: '-80px' }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="text-center max-w-3xl mx-auto"
|
className="text-center max-w-3xl mx-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl sm:text-4xl font-semibold text-white mb-4">
|
<motion.div
|
||||||
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: -8 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.15 }}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 mb-8"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-[var(--color-brand-primary)]" />
|
||||||
|
<span className="text-sm font-medium text-white/80">开始合作</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl sm:text-5xl lg:text-6xl font-semibold text-white mb-6 tracking-tight leading-tight">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-[#A3A3A3] mb-10">
|
<p className="text-lg md:text-xl text-white/65 leading-relaxed mb-12 max-w-2xl mx-auto">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<Button size="lg" asChild>
|
<Button size="lg" asChild className="group relative overflow-hidden">
|
||||||
<StaticLink href={primaryHref}>
|
<StaticLink href={primaryHref}>
|
||||||
{primaryLabel}
|
<span className="relative z-10 flex items-center">
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
{primaryLabel}
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</span>
|
||||||
</StaticLink>
|
</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" className="border-white/20 text-white hover:bg-white/10" asChild>
|
<Button
|
||||||
<StaticLink href={secondaryHref}>
|
size="lg"
|
||||||
{secondaryLabel}
|
variant="outline"
|
||||||
</StaticLink>
|
className="border-white/20 text-white hover:bg-white/10 hover:border-white/35 backdrop-blur-sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<StaticLink href={secondaryHref}>{secondaryLabel}</StaticLink>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,101 +1,193 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { HeroInkBackground } from '@/components/ui/hero-ink-background';
|
||||||
import { COMPANY_INFO } from '@/lib/constants';
|
import { COMPANY_INFO } from '@/lib/constants';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight, MessageSquare, Search, Rocket, Handshake } from 'lucide-react';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
|
const EASE = [0.16, 1, 0.3, 1] as const;
|
||||||
|
|
||||||
|
const CAPABILITIES = [
|
||||||
|
{ icon: MessageSquare, label: '需求沟通' },
|
||||||
|
{ icon: Search, label: '方案诊断' },
|
||||||
|
{ icon: Rocket, label: '敏捷交付' },
|
||||||
|
{ icon: Handshake, label: '长期陪跑' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOURNEY_STEPS = [
|
||||||
|
{ icon: MessageSquare, label: '需求沟通', desc: '深入理解您的业务痛点' },
|
||||||
|
{ icon: Search, label: '方案诊断', desc: '量身定制技术路径' },
|
||||||
|
{ icon: Rocket, label: '敏捷交付', desc: '快速迭代持续验证' },
|
||||||
|
{ icon: Handshake, label: '长期陪跑', desc: '持续优化保障落地' },
|
||||||
|
];
|
||||||
|
|
||||||
export function HeroSectionV2() {
|
export function HeroSectionV2() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCardMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
const observer = new IntersectionObserver(
|
if (!cardRef.current) { return; }
|
||||||
([entry]) => {
|
const rect = cardRef.current.getBoundingClientRect();
|
||||||
if (entry?.isIntersecting) {
|
setMousePos({
|
||||||
setIsVisible(true);
|
x: e.clientX - rect.left,
|
||||||
}
|
y: e.clientY - rect.top,
|
||||||
},
|
});
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sectionRef.current) {
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fadeUp = {
|
const fadeUp = (delay: number) => ({
|
||||||
initial: shouldReduceMotion ? {} : { opacity: 0, y: 24 },
|
initial: shouldReduceMotion ? {} : { opacity: 0, y: 24 },
|
||||||
animate: isVisible ? { opacity: 1, y: 0 } : {},
|
animate: { opacity: 1, y: 0 },
|
||||||
};
|
transition: { duration: 0.5, delay, ease: EASE },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="home"
|
id="home"
|
||||||
ref={sectionRef}
|
|
||||||
aria-labelledby="hero-heading"
|
aria-labelledby="hero-heading"
|
||||||
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-white"
|
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-[var(--color-bg-primary)]"
|
||||||
>
|
>
|
||||||
|
<HeroInkBackground />
|
||||||
|
|
||||||
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10 flex-1 flex items-center">
|
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10 flex-1 flex items-center">
|
||||||
<div className="max-w-3xl">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center w-full">
|
||||||
<motion.div
|
<div>
|
||||||
{...fadeUp}
|
<motion.div
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
{...fadeUp(0)}
|
||||||
className="mb-6"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#FEF2F4] text-[#C41E3A] text-sm font-medium border border-[#C41E3A]/10">
|
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-medium border border-[var(--color-brand-primary)]/10">
|
||||||
智连未来,成长伙伴
|
{COMPANY_INFO.slogan}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
id="hero-heading"
|
id="hero-heading"
|
||||||
{...fadeUp}
|
{...fadeUp(0.1)}
|
||||||
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
className="text-6xl sm:text-7xl lg:text-8xl tracking-tight mb-6 font-brand"
|
||||||
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-brand"
|
style={{ fontWeight: 'normal' }}
|
||||||
style={{ fontWeight: 'normal' }}
|
>
|
||||||
>
|
{COMPANY_INFO.shortName}
|
||||||
{COMPANY_INFO.shortName}
|
</motion.h1>
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
{...fadeUp}
|
{...fadeUp(0.2)}
|
||||||
transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
className="text-xl sm:text-2xl text-[var(--color-text-primary)] mb-4"
|
||||||
className="text-xl sm:text-2xl text-[#1C1C1C] mb-4"
|
>
|
||||||
>
|
<span className="font-semibold text-[var(--color-brand-primary)] font-calligraphy">
|
||||||
<span className="font-semibold text-[#C41E3A]">企业数字化转型服务商</span>
|
企业数字化转型服务商
|
||||||
</motion.p>
|
</span>
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
{...fadeUp}
|
{...fadeUp(0.3)}
|
||||||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
className="text-lg text-[var(--color-text-muted)] max-w-xl leading-relaxed mb-10"
|
||||||
className="text-lg text-[#595959] max-w-2xl leading-relaxed mb-10"
|
>
|
||||||
>
|
{COMPANY_INFO.description}
|
||||||
以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者
|
</motion.p>
|
||||||
</motion.p>
|
|
||||||
|
<motion.div
|
||||||
|
{...fadeUp(0.4)}
|
||||||
|
className="flex flex-col sm:flex-row items-start gap-4"
|
||||||
|
>
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<StaticLink href="/contact">
|
||||||
|
免费获取定制方案
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</StaticLink>
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" asChild>
|
||||||
|
<StaticLink href="/products">探索产品</StaticLink>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
{...fadeUp(0.5)}
|
||||||
|
className="flex flex-wrap gap-3 mt-10 lg:hidden"
|
||||||
|
>
|
||||||
|
{CAPABILITIES.map((cap) => {
|
||||||
|
const Icon = cap.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cap.label}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-bg-section)] border border-[var(--color-border-primary)]"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-[var(--color-brand-primary)]" strokeWidth={2.2} />
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-primary)]">{cap.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
{...fadeUp}
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: 32 }}
|
||||||
transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex flex-col sm:flex-row items-start gap-4"
|
transition={{ duration: 0.6, delay: 0.5, ease: EASE }}
|
||||||
|
className="hidden lg:block"
|
||||||
>
|
>
|
||||||
<Button size="lg" asChild>
|
<div
|
||||||
<StaticLink href="/contact">
|
ref={cardRef}
|
||||||
立即咨询
|
className="relative ink-glow-border rounded-2xl"
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
style={
|
||||||
</StaticLink>
|
{
|
||||||
</Button>
|
'--glow-start': 'var(--color-brand-primary)',
|
||||||
<Button size="lg" variant="outline" asChild>
|
'--glow-end': 'var(--color-warning)',
|
||||||
<StaticLink href="/products">
|
} as React.CSSProperties
|
||||||
探索产品
|
}
|
||||||
</StaticLink>
|
onMouseMove={handleCardMouseMove}
|
||||||
</Button>
|
onMouseEnter={() => setIsCardHovered(true)}
|
||||||
|
onMouseLeave={() => setIsCardHovered(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-2xl pointer-events-none transition-opacity duration-500"
|
||||||
|
style={{
|
||||||
|
opacity: isCardHovered ? 1 : 0,
|
||||||
|
background: `radial-gradient(400px circle at ${mousePos.x}px ${mousePos.y}px, rgba(var(--color-brand-primary-rgb), 0.06), transparent 40%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative rounded-2xl bg-[var(--color-bg-primary)]/80 backdrop-blur-sm p-8 lg:p-10">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-[var(--color-brand-primary-bg)] flex items-center justify-center mb-6">
|
||||||
|
<Handshake className="w-6 h-6 text-[var(--color-brand-primary)]" strokeWidth={1.8} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-2">
|
||||||
|
从沟通到落地,全程陪伴
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-8">
|
||||||
|
四步合作流程,确保每个项目科学推进、高效交付
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{JOURNEY_STEPS.map((step, idx) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.label}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-lg bg-[var(--color-bg-section)]/60"
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-[var(--color-brand-primary-bg)] flex items-center justify-center shrink-0">
|
||||||
|
<Icon className="w-4 h-4 text-[var(--color-brand-primary)]" strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-[var(--color-brand-primary)]">0{idx + 1}</span>
|
||||||
|
<span className="text-sm font-medium text-[var(--color-text-primary)]">{step.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">{step.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight, Lightbulb, Cpu, Users } from 'lucide-react';
|
|
||||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
|
||||||
|
|
||||||
const SOLUTIONS_OVERVIEW = [
|
|
||||||
{
|
|
||||||
icon: Lightbulb,
|
|
||||||
title: '参谋伙伴',
|
|
||||||
subtitle: '数字化转型咨询',
|
|
||||||
description: '帮您看清前路,迈对第一步。用行业智慧洞察趋势,用理性分析避开陷阱。',
|
|
||||||
points: ['行业趋势洞察报告', '成熟度评估', '实施路径规划'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Cpu,
|
|
||||||
title: '技术伙伴',
|
|
||||||
subtitle: '信息技术解决方案',
|
|
||||||
description: '让技术真正为业务服务。不追逐"最火"的技术,只选择"最对"的技术。',
|
|
||||||
points: ['业务场景调研', '技术方案定制', '敏捷交付迭代'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
title: '同行伙伴',
|
|
||||||
subtitle: '长期陪跑服务',
|
|
||||||
description: '从需求理解到产品落地,陪伴才是常态。当产品真正为您所用那天,才是我们成为伙伴的开始。',
|
|
||||||
points: ['专属客户成功经理', '季度业务复盘', '7×24小时响应'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const solutionAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
|
|
||||||
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
|
|
||||||
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
|
|
||||||
{ rgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function HomeSolutionsSection() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="solutions" role="region" aria-labelledby="solutions-heading" className="py-20 md:py-28 bg-white" ref={ref}>
|
|
||||||
<div className="container-wide">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
|
||||||
>
|
|
||||||
<h2 id="solutions-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
|
||||||
三种角色,一种<span className="text-[#C41E3A] font-calligraphy">身份</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959]">
|
|
||||||
您的数字化转型成长伙伴——从战略咨询到技术落地,再到长期陪跑
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{SOLUTIONS_OVERVIEW.map((item, idx) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const accent = solutionAccents[idx % solutionAccents.length]!;
|
|
||||||
return (
|
|
||||||
<InkGlowCard
|
|
||||||
key={item.title}
|
|
||||||
index={idx}
|
|
||||||
accentColorRgb={accent.rgb}
|
|
||||||
glowStart={accent.glowStart}
|
|
||||||
glowEnd={accent.glowEnd}
|
|
||||||
>
|
|
||||||
<div className="p-6 md:p-8">
|
|
||||||
<div
|
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center mb-5"
|
|
||||||
style={{ backgroundColor: `rgba(${accent.rgb}, 0.06)` }}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" style={{ color: accent.glowStart }} strokeWidth={1.8} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-1">{item.title}</h3>
|
|
||||||
<p className="text-xs text-[#C41E3A] font-medium mb-3">{item.subtitle}</p>
|
|
||||||
<p className="text-sm text-[#595959] leading-relaxed mb-5">{item.description}</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{item.points.map((point, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-2 text-sm text-[#595959]">
|
|
||||||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-1.5 shrink-0" />
|
|
||||||
{point}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</InkGlowCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="mt-12 text-center"
|
|
||||||
>
|
|
||||||
<Button size="lg" asChild>
|
|
||||||
<StaticLink href="/solutions">
|
|
||||||
了解完整解决方案
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,10 @@ import { CheckCircle2 } from 'lucide-react';
|
|||||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
||||||
|
|
||||||
const phaseAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
|
const phaseAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
|
||||||
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
|
{ rgb: '196, 30, 58', glowStart: 'var(--color-brand-primary)', glowEnd: 'var(--color-warning)' },
|
||||||
{ rgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
|
{ rgb: '217, 119, 6', glowStart: 'var(--color-warning)', glowEnd: 'var(--color-success)' },
|
||||||
{ rgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
|
{ rgb: '22, 163, 74', glowStart: 'var(--color-success)', glowEnd: 'var(--color-accent-cyan)' },
|
||||||
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
|
{ rgb: '37, 99, 235', glowStart: 'var(--color-accent-blue)', glowEnd: 'var(--color-accent-purple)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MethodologySection() {
|
export function MethodologySection() {
|
||||||
@@ -19,7 +19,7 @@ export function MethodologySection() {
|
|||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="methodology" role="region" aria-labelledby="methodology-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
|
<section id="methodology" role="region" aria-labelledby="methodology-heading" className="py-20 md:py-28 bg-[var(--color-bg-section)]" ref={ref}>
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -27,16 +27,17 @@ export function MethodologySection() {
|
|||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
className="text-center max-w-3xl mx-auto mb-14"
|
||||||
>
|
>
|
||||||
<h2 id="methodology-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
<h2 id="methodology-heading" className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] mb-4">
|
||||||
实施<span className="text-[#C41E3A] font-calligraphy">方法论</span>
|
实施<span className="text-[var(--color-brand-primary)]">方法论</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-[#595959]">
|
<p className="text-base text-[var(--color-text-muted)]">
|
||||||
经过多年实践验证的四阶段模型,确保每个项目都能科学推进、高效落地
|
经过多年实践验证的四阶段模型,确保每个项目都能科学推进、高效落地
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="hidden lg:block absolute top-16 left-[12.5%] right-[12.5%] h-px bg-gradient-to-r from-transparent via-[#E5E5E5] to-transparent" />
|
<div className="hidden lg:block absolute top-16 left-[12.5%] right-[12.5%] h-px bg-gradient-to-r from-transparent via-[var(--color-border-primary)] to-transparent" />
|
||||||
|
<div className="hidden md:block lg:hidden absolute left-[calc(50%+0.375rem)] top-0 bottom-0 w-px bg-[var(--color-border-primary)]" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||||
{METHODOLOGY.map((phase, idx) => {
|
{METHODOLOGY.map((phase, idx) => {
|
||||||
@@ -52,21 +53,21 @@ export function MethodologySection() {
|
|||||||
<div className="p-6 md:p-8">
|
<div className="p-6 md:p-8">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-full flex items-center justify-center mb-5 text-sm font-bold"
|
className="w-10 h-10 rounded-full flex items-center justify-center mb-5 text-sm font-bold"
|
||||||
style={{ backgroundColor: accent.glowStart, color: '#FFFFFF' }}
|
style={{ backgroundColor: accent.glowStart, color: 'var(--color-bg-primary)' }}
|
||||||
>
|
>
|
||||||
{phase.number}
|
{phase.number}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-1">{phase.title}</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-1">{phase.title}</h3>
|
||||||
<p className="text-xs text-[#C41E3A] font-medium mb-3">{phase.subtitle}</p>
|
<p className="text-xs text-[var(--color-brand-primary)] font-medium mb-3">{phase.subtitle}</p>
|
||||||
<p className="text-sm text-[#595959] leading-relaxed mb-5">{phase.description}</p>
|
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-5">{phase.description}</p>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 tracking-wide">核心活动</p>
|
<p className="text-xs font-semibold text-[var(--color-text-primary)] mb-2 tracking-wide">核心活动</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{phase.activities.map((activity, i) => (
|
{phase.activities.map((activity, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-xs text-[#595959]">
|
<li key={i} className="flex items-start gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
<CheckCircle2 className="w-3.5 h-3.5 text-[#C41E3A] mt-0.5 shrink-0" />
|
<CheckCircle2 className="w-3.5 h-3.5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||||||
{activity}
|
{activity}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -74,11 +75,11 @@ export function MethodologySection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 tracking-wide">交付物</p>
|
<p className="text-xs font-semibold text-[var(--color-text-primary)] mb-2 tracking-wide">交付物</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{phase.deliverables.map((deliverable, i) => (
|
{phase.deliverables.map((deliverable, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-xs text-[#A3A3A3]">
|
<li key={i} className="flex items-start gap-2 text-xs text-[var(--color-text-subtle)]">
|
||||||
<span className="w-1.5 h-1.5 bg-[#C41E3A]/40 rounded-full mt-1.5 shrink-0" />
|
<span className="w-1.5 h-1.5 bg-[var(--color-brand-primary)]/40 rounded-full mt-1.5 shrink-0" />
|
||||||
{deliverable}
|
{deliverable}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight } from 'lucide-react';
|
|
||||||
import { InsightCard } from '@/components/ui/insight-card';
|
|
||||||
import { NEWS } from '@/lib/constants';
|
|
||||||
|
|
||||||
export function NewsSection() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="news" role="region" aria-labelledby="news-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
|
|
||||||
<div className="container-wide">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
|
||||||
>
|
|
||||||
<h2 id="news-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
|
||||||
最新<span className="text-[#C41E3A] font-calligraphy">动态</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959]">
|
|
||||||
洞察行业趋势,分享实践经验
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{NEWS.slice(0, 3).map((item, idx) => (
|
|
||||||
<InsightCard
|
|
||||||
key={item.id}
|
|
||||||
title={item.title}
|
|
||||||
excerpt={item.excerpt}
|
|
||||||
category={item.category}
|
|
||||||
readTime="5 分钟"
|
|
||||||
publishedAt={item.date}
|
|
||||||
imageUrl={item.image}
|
|
||||||
href={`/news/${item.id}`}
|
|
||||||
featured={idx === 0}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="mt-12 text-center"
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="lg" className="group" asChild>
|
|
||||||
<StaticLink href="/news">
|
|
||||||
查看全部动态
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ProductCard } from '@/components/ui/product-card';
|
import { ProductCard } from '@/components/ui/product-card';
|
||||||
import { PRODUCTS } from '@/lib/constants';
|
import { PRODUCTS } from '@/lib/constants';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
export function ProductMatrixSection() {
|
export function ProductMatrixSection() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry?.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sectionRef.current) {
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="products" className="relative py-16 md:py-20 bg-[var(--color-bg-primary)]">
|
||||||
id="products"
|
|
||||||
ref={sectionRef}
|
|
||||||
className="py-20 md:py-28 bg-white"
|
|
||||||
>
|
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : {}}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-100px' }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-14"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
<div>
|
||||||
产品<span className="text-[#C41E3A] font-calligraphy">矩阵</span>
|
<h2 className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] mb-3">
|
||||||
</h2>
|
产品矩阵
|
||||||
<p className="text-base text-[#595959]">
|
</h2>
|
||||||
覆盖企业数字化全场景,从管理到决策,一站式解决方案规划
|
<p className="text-base text-[var(--color-text-muted)] max-w-lg">
|
||||||
</p>
|
覆盖企业数字化全场景,从管理到决策,一站式解决方案规划
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[var(--color-text-subtle)] font-mono tracking-wider hidden md:block">
|
||||||
|
{PRODUCTS.length} PRODUCTS
|
||||||
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
{PRODUCTS.map((product, index) => (
|
{PRODUCTS.map((product, index) => (
|
||||||
<ProductCard
|
<motion.div
|
||||||
key={product.id}
|
key={product.id}
|
||||||
title={product.title}
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: 28 }}
|
||||||
description={product.description}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
href={`/products/${product.id}`}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
index={index}
|
transition={{ duration: 0.5, delay: index * 0.08, ease: [0.16, 1, 0.3, 1] }}
|
||||||
status={product.status}
|
>
|
||||||
/>
|
<ProductCard
|
||||||
|
title={product.title}
|
||||||
|
description={product.description}
|
||||||
|
href={`/products/${product.id}`}
|
||||||
|
index={index}
|
||||||
|
status={product.status}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
|
|
||||||
import { PRODUCTS } from '@/lib/constants';
|
|
||||||
|
|
||||||
export function ProductsSection() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
|
|
||||||
<div className="absolute top-1/2 left-0 w-[400px] h-[400px] bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
|
|
||||||
<div className="absolute top-1/3 right-0 w-[300px] h-[300px] bg-[rgba(196,30,58,0.02)] rounded-full blur-3xl" />
|
|
||||||
<div className="container-wide relative z-10">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.6 }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-16"
|
|
||||||
>
|
|
||||||
<h2 id="products-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
|
|
||||||
我们正在打造的<span className="text-[#C41E3A] font-calligraphy">产品</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-[#5C5C5C]">
|
|
||||||
我们正在打造的企业级产品,致力于助力企业高效运营,实现数字化转型
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{PRODUCTS.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{PRODUCTS.map((product, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={product.id}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }}
|
|
||||||
>
|
|
||||||
<StaticLink href={`/products/${product.id}`}>
|
|
||||||
<Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C] transition-colors">
|
|
||||||
<CardHeader>
|
|
||||||
<Badge variant="secondary" className="w-fit mb-3">
|
|
||||||
{product.category}
|
|
||||||
</Badge>
|
|
||||||
<CardTitle>{product.title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 flex flex-col">
|
|
||||||
<CardDescription className="text-base leading-relaxed mb-4 flex-1">
|
|
||||||
{product.description}
|
|
||||||
</CardDescription>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2">规划功能</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{product.features.slice(0, 4).map((feature, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className="inline-flex items-center text-xs px-2 py-1 bg-[#FAFAFA] text-[#3D3D3D] rounded border border-[#E5E5E5]"
|
|
||||||
>
|
|
||||||
<Check className="w-3 h-3 mr-1 text-[#C41E3A]" />
|
|
||||||
{feature}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2 flex items-center">
|
|
||||||
<TrendingUp className="w-4 h-4 mr-1 text-[#C41E3A]" />
|
|
||||||
核心价值
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{product.benefits.map((benefit, idx) => (
|
|
||||||
<li key={idx} className="text-xs text-[#5C5C5C] flex items-start">
|
|
||||||
<span className="text-[#C41E3A] mr-1.5">•</span>
|
|
||||||
{benefit}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#A01830] group-hover:text-white group-hover:border-[#A01830] transition-colors">
|
|
||||||
了解规划
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</StaticLink>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-lg text-[#5C5C5C]">暂无产品信息</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.6, delay: 0.5 }}
|
|
||||||
className="mt-20 text-center"
|
|
||||||
>
|
|
||||||
<div className="bg-white rounded-2xl p-12 border border-[#E2E8F0] relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
|
|
||||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-[rgba(196,30,58,0.02)] rounded-full blur-3xl" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h3 className="text-2xl sm:text-3xl font-bold text-[#1A1A2E] mb-4">
|
|
||||||
期待与您共同打磨产品
|
|
||||||
</h3>
|
|
||||||
<p className="text-[#718096] mb-8 max-w-2xl mx-auto">
|
|
||||||
我们的产品正在研发中,如果您对产品方向有建议或希望参与早期体验,欢迎联系我们
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<StaticLink href="/contact">
|
|
||||||
预约体验
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
|
||||||
import { Code, BarChart3, Lightbulb, Puzzle, ArrowRight } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
|
||||||
import { SERVICES } from '@/lib/constants';
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
|
||||||
Code,
|
|
||||||
BarChart3,
|
|
||||||
Lightbulb,
|
|
||||||
Puzzle,
|
|
||||||
};
|
|
||||||
|
|
||||||
const serviceAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
|
|
||||||
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
|
|
||||||
{ rgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
|
|
||||||
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
|
|
||||||
{ rgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ServicesSection() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="services" aria-labelledby="services-heading" className="py-20 md:py-28 bg-white" ref={ref}>
|
|
||||||
<div className="container-wide">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
|
||||||
>
|
|
||||||
<h2 id="services-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
|
||||||
我们的 <span className="text-[#C41E3A] font-calligraphy">专业服务</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959] max-w-2xl mx-auto">
|
|
||||||
专业技术团队,为您提供全方位的数字化解决方案
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{SERVICES.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
|
||||||
{SERVICES.map((service, index) => {
|
|
||||||
const Icon = iconMap[service.icon];
|
|
||||||
const accent = serviceAccents[index % serviceAccents.length]!;
|
|
||||||
return (
|
|
||||||
<InkGlowCard
|
|
||||||
key={service.id}
|
|
||||||
index={index}
|
|
||||||
href={`/services/${service.id}`}
|
|
||||||
accentColorRgb={accent.rgb}
|
|
||||||
glowStart={accent.glowStart}
|
|
||||||
glowEnd={accent.glowEnd}
|
|
||||||
>
|
|
||||||
<div className="p-6 md:p-8">
|
|
||||||
<div
|
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center mb-5"
|
|
||||||
style={{ backgroundColor: `rgba(${accent.rgb}, 0.06)` }}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-5 h-5 text-[#C41E3A]" strokeWidth={1.8} />}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2">{service.title}</h3>
|
|
||||||
<p className="text-sm text-[#595959] leading-relaxed mb-4">{service.description}</p>
|
|
||||||
<div className="flex items-center text-sm font-medium text-[#C41E3A]">
|
|
||||||
了解详情
|
|
||||||
<ArrowRight className="ml-1 w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</InkGlowCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-base text-[#595959]">暂无服务信息</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center mt-12"
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="lg" className="group" asChild>
|
|
||||||
<StaticLink href="/services">
|
|
||||||
查看全部服务
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
</StaticLink>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,70 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useInView } from 'framer-motion';
|
import { Shield, Users, Cpu, Handshake } from 'lucide-react';
|
||||||
import { useRef } from 'react';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { Building2, Users, Award, TrendingUp } from 'lucide-react';
|
|
||||||
|
|
||||||
const STATS = [
|
const TRUST_PILLARS = [
|
||||||
{ icon: Building2, value: '6', label: '研发产品' },
|
{
|
||||||
{ icon: Users, value: '10+', label: '团队成员' },
|
icon: Shield,
|
||||||
{ icon: Award, value: '5+', label: '行业覆盖' },
|
title: '私有化部署',
|
||||||
{ icon: TrendingUp, value: '12+', label: '年核心团队经验' },
|
description: '数据不出企业,满足安全合规与数据主权要求',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: '资深团队',
|
||||||
|
description: '核心成员来自大型 IT 企业,具备扎实的工程能力与规范化交付经验',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Cpu,
|
||||||
|
title: '全栈自研',
|
||||||
|
description: '6 款产品自主研发中,覆盖 ERP、CRM、BI 等企业核心场景',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Handshake,
|
||||||
|
title: '长期陪跑',
|
||||||
|
description: '不做完就跑,从需求理解到产品打磨,持续陪伴客户成长',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SocialProofSection() {
|
export function SocialProofSection() {
|
||||||
const ref = useRef(null);
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="social-proof" role="region" aria-labelledby="social-proof-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
|
<section id="social-proof" role="region" aria-labelledby="social-proof-heading" className="py-16 md:py-20 bg-[var(--color-bg-section)]">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-100px' }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
className="text-center max-w-3xl mx-auto mb-14"
|
||||||
>
|
>
|
||||||
<h2 id="social-proof-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
<h2 id="social-proof-heading" className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] mb-4">
|
||||||
值得<span className="text-[#C41E3A] font-calligraphy">信赖</span>
|
值得信赖
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-[#595959]">
|
<p className="text-base text-[var(--color-text-muted)]">
|
||||||
用心打磨产品,以专业赢得信赖
|
用心打磨产品,以专业赢得信赖
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||||
{STATS.map((stat, idx) => {
|
{TRUST_PILLARS.map((pillar, idx) => {
|
||||||
const Icon = stat.icon;
|
const Icon = pillar.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={stat.label}
|
key={pillar.title}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24, scale: 0.94 }}
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
transition={{ duration: 0.5, delay: idx * 0.1, ease: [0.16, 1, 0.3, 1] }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
className="text-center p-6 md:p-8"
|
transition={{ duration: 0.5, delay: idx * 0.1 + 0.05, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="text-center p-6 md:p-8 rounded-xl bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] hover:border-[rgba(var(--color-brand-primary-rgb),0.3)] transition-colors duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 rounded-xl bg-[#C41E3A]/5 flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 rounded-xl bg-[var(--color-brand-primary)]/5 flex items-center justify-center mx-auto mb-4">
|
||||||
<Icon className="w-5 h-5 text-[#C41E3A]" strokeWidth={1.8} />
|
<Icon className="w-5 h-5 text-[var(--color-brand-primary)]" strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-1">{stat.value}</div>
|
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-2">{pillar.title}</h3>
|
||||||
<div className="text-sm text-[#A3A3A3]">{stat.label}</div>
|
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">{pillar.description}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { TEAM_MEMBERS } from '@/lib/constants';
|
|
||||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
|
||||||
|
|
||||||
export function TeamSection() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="team" role="region" aria-labelledby="team-heading" className="py-20 md:py-28 bg-white" ref={ref}>
|
|
||||||
<div className="container-wide">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
|
||||||
>
|
|
||||||
<h2 id="team-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
|
||||||
核心<span className="text-[#C41E3A] font-calligraphy">团队</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959]">
|
|
||||||
来自行业领先企业的资深专家,用实战经验为您护航
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
|
||||||
{TEAM_MEMBERS.map((member, idx) => (
|
|
||||||
<InkGlowCard
|
|
||||||
key={member.name}
|
|
||||||
index={idx}
|
|
||||||
accentColorRgb="196, 30, 58"
|
|
||||||
glowStart="#C41E3A"
|
|
||||||
glowEnd="#D97706"
|
|
||||||
>
|
|
||||||
<div className="p-6 md:p-8 text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-[#FAFAFA] mx-auto mb-4 flex items-center justify-center">
|
|
||||||
<span className="text-xl font-semibold text-[#C41E3A]">
|
|
||||||
{member.name.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-base font-semibold text-[#1C1C1C] mb-1">{member.name}</h3>
|
|
||||||
<p className="text-xs text-[#C41E3A] font-medium mb-2">{member.title}</p>
|
|
||||||
<p className="text-sm text-[#595959] leading-relaxed">{member.bio}</p>
|
|
||||||
</div>
|
|
||||||
</InkGlowCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { TestimonialBlock } from '@/components/ui/testimonial-block';
|
|
||||||
|
|
||||||
const TESTIMONIALS = [
|
|
||||||
{
|
|
||||||
quote: '我们相信,好的企业管理软件应该像水一样自然——融入业务流程,而不是让业务流程去适应软件。',
|
|
||||||
author: '产品理念',
|
|
||||||
title: '产品设计原则',
|
|
||||||
company: '睿新致远',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quote: '数据不应被锁在孤岛里。打通信息壁垒,让每一个决策都有数据支撑,这是我们打造BI平台的初心。',
|
|
||||||
author: '技术愿景',
|
|
||||||
title: '技术方向',
|
|
||||||
company: '睿新致远',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quote: '我们不做"做完就跑"的供应商。从需求理解到产品打磨,我们希望成为企业数字化转型路上真正的同行者。',
|
|
||||||
author: '服务承诺',
|
|
||||||
title: '核心价值观',
|
|
||||||
company: '睿新致远',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TestimonialSection() {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry?.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sectionRef.current) {
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="testimonials"
|
|
||||||
ref={sectionRef}
|
|
||||||
className="py-20 md:py-28 bg-[#FAFAFA]"
|
|
||||||
>
|
|
||||||
<div className="container-wide">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="text-center max-w-3xl mx-auto mb-14"
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
|
|
||||||
我们的<span className="text-[#C41E3A] font-calligraphy">理念</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-[#595959]">
|
|
||||||
产品未动,理念先行——我们如何思考,决定了我们打造怎样的产品
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{TESTIMONIALS.map((testimonial, index) => (
|
|
||||||
<TestimonialBlock
|
|
||||||
key={testimonial.author}
|
|
||||||
quote={testimonial.quote}
|
|
||||||
author={testimonial.author}
|
|
||||||
title={testimonial.title}
|
|
||||||
company={testimonial.company}
|
|
||||||
index={index}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -74,3 +74,91 @@ export function ServiceSchema() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BreadcrumbSchema({ items }: { items: BreadcrumbItem[] }) {
|
||||||
|
const schema = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": items.map((item, idx) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": idx + 1,
|
||||||
|
"name": item.name,
|
||||||
|
"item": `https://www.novalon.cn${item.href}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalBusinessSchema() {
|
||||||
|
const schema = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "LocalBusiness",
|
||||||
|
"@id": "https://www.novalon.cn/#business",
|
||||||
|
"name": COMPANY_INFO.name,
|
||||||
|
"image": "https://www.novalon.cn/og-image.jpg",
|
||||||
|
"url": "https://www.novalon.cn",
|
||||||
|
"email": COMPANY_INFO.email,
|
||||||
|
"description": "专注于企业数字化转型服务,提供软件开发、云计算、数据分析、信息安全等一站式解决方案",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "成都市高新区",
|
||||||
|
"addressLocality": "成都市",
|
||||||
|
"addressRegion": "四川省",
|
||||||
|
"postalCode": "610000",
|
||||||
|
"addressCountry": "CN"
|
||||||
|
},
|
||||||
|
"geo": {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
"latitude": 30.5728,
|
||||||
|
"longitude": 104.0668
|
||||||
|
},
|
||||||
|
"openingHoursSpecification": [
|
||||||
|
{
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||||
|
"opens": "09:00",
|
||||||
|
"closes": "18:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"priceRange": "$$",
|
||||||
|
"currenciesAccepted": "CNY",
|
||||||
|
"paymentAccepted": "Cash, Credit Card, Bank Transfer",
|
||||||
|
"areaServed": [
|
||||||
|
{ "@type": "City", "name": "成都" },
|
||||||
|
{ "@type": "State", "name": "四川" },
|
||||||
|
{ "@type": "Country", "name": "中国" }
|
||||||
|
],
|
||||||
|
"hasOfferCatalog": {
|
||||||
|
"@type": "OfferCatalog",
|
||||||
|
"name": "数字化转型服务",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "软件开发服务" } },
|
||||||
|
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "云计算解决方案" } },
|
||||||
|
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "数据分析与BI" } },
|
||||||
|
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "信息安全咨询" } },
|
||||||
|
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "企业IT架构设计" } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://www.novalon.cn"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { AnimatedNumber, StatCard } from './animated-number';
|
|
||||||
|
|
||||||
jest.mock('framer-motion', () => ({
|
|
||||||
motion: {
|
|
||||||
span: ({ children, ...props }: any) => <span {...props}>{children}</span>,
|
|
||||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
||||||
},
|
|
||||||
useInView: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('AnimatedNumber', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render number', () => {
|
|
||||||
render(<AnimatedNumber value={100} />);
|
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with prefix', () => {
|
|
||||||
render(<AnimatedNumber value={100} prefix="$" />);
|
|
||||||
expect(screen.getByText(/\$0/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with suffix', () => {
|
|
||||||
render(<AnimatedNumber value={100} suffix="+" />);
|
|
||||||
expect(screen.getByText(/0\+/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with prefix and suffix', () => {
|
|
||||||
render(<AnimatedNumber value={100} prefix="$" suffix="+" />);
|
|
||||||
expect(screen.getByText(/\$0\+/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with custom className', () => {
|
|
||||||
const { container } = render(<AnimatedNumber value={100} className="custom-class" />);
|
|
||||||
const element = container.querySelector('.custom-class');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Animation', () => {
|
|
||||||
it('should accept duration prop', () => {
|
|
||||||
render(<AnimatedNumber value={100} duration={3000} />);
|
|
||||||
const element = screen.getByText('0');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept delay prop', () => {
|
|
||||||
render(<AnimatedNumber value={100} delay={500} />);
|
|
||||||
const element = screen.getByText('0');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should start from 0', () => {
|
|
||||||
render(<AnimatedNumber value={100} />);
|
|
||||||
const element = screen.getByText('0');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('should handle zero value', () => {
|
|
||||||
render(<AnimatedNumber value={0} />);
|
|
||||||
const element = screen.getByText('0');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle large numbers', () => {
|
|
||||||
render(<AnimatedNumber value={1000000} />);
|
|
||||||
const element = screen.getByText('0');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle decimal numbers', () => {
|
|
||||||
render(<AnimatedNumber value={99} />);
|
|
||||||
const element = screen.getByText('0');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('StatCard', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render stat card', () => {
|
|
||||||
render(<StatCard value={100} label="Users" />);
|
|
||||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with prefix', () => {
|
|
||||||
render(<StatCard value={100} label="Revenue" prefix="$" />);
|
|
||||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with suffix', () => {
|
|
||||||
render(<StatCard value={100} label="Growth" suffix="%" />);
|
|
||||||
expect(screen.getByText('Growth')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with prefix and suffix', () => {
|
|
||||||
render(<StatCard value={100} label="Score" prefix="+" suffix="pts" />);
|
|
||||||
expect(screen.getByText('Score')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with index', () => {
|
|
||||||
render(<StatCard value={100} label="Users" index={2} />);
|
|
||||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Styling', () => {
|
|
||||||
it('should have text-center class', () => {
|
|
||||||
const { container } = render(<StatCard value={100} label="Users" />);
|
|
||||||
const card = container.querySelector('.text-center');
|
|
||||||
expect(card).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have group class', () => {
|
|
||||||
const { container } = render(<StatCard value={100} label="Users" />);
|
|
||||||
const card = container.querySelector('.group');
|
|
||||||
expect(card).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user