🎉 first commit
This commit is contained in:
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
||||
# Ignore Git and GitHub files
|
||||
.git
|
||||
.github/
|
||||
|
||||
# Ignore Husky configuration files
|
||||
.husky/
|
||||
|
||||
# Ignore documentation and metadata files
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
# Ignore environment examples and sensitive info
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
*.example
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Ignore node modules, logs and cache files
|
||||
**/*.log
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/.cache
|
||||
logs
|
||||
dist-ssr
|
||||
.DS_Store
|
||||
|
||||
# Ignore any potential secrets or key files
|
||||
**/*.pem
|
||||
**/*.key
|
||||
**/secrets/
|
||||
**/credentials/
|
||||
13
.editorconfig
Normal file
13
.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
158
.env.example
Normal file
158
.env.example
Normal file
@@ -0,0 +1,158 @@
|
||||
# Rename this file to .env once you have filled in the below environment variables!
|
||||
|
||||
# Get your GROQ API Key here -
|
||||
# https://console.groq.com/keys
|
||||
# You only need this environment variable set if you want to use Groq models
|
||||
GROQ_API_KEY=
|
||||
|
||||
# Get your HuggingFace API Key here -
|
||||
# https://huggingface.co/settings/tokens
|
||||
# You only need this environment variable set if you want to use HuggingFace models
|
||||
HuggingFace_API_KEY=
|
||||
|
||||
|
||||
# Get your Open AI API Key by following these instructions -
|
||||
# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
|
||||
# You only need this environment variable set if you want to use GPT models
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Get your Anthropic API Key in your account settings -
|
||||
# https://console.anthropic.com/settings/keys
|
||||
# You only need this environment variable set if you want to use Claude models
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Get your OpenRouter API Key in your account settings -
|
||||
# https://openrouter.ai/settings/keys
|
||||
# You only need this environment variable set if you want to use OpenRouter models
|
||||
OPEN_ROUTER_API_KEY=
|
||||
|
||||
# Get your Google Generative AI API Key by following these instructions -
|
||||
# https://console.cloud.google.com/apis/credentials
|
||||
# You only need this environment variable set if you want to use Google Generative AI models
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
|
||||
# You only need this environment variable set if you want to use oLLAMA models
|
||||
# DONT USE http://localhost:11434 due to IPV6 issues
|
||||
# USE EXAMPLE http://127.0.0.1:11434
|
||||
OLLAMA_API_BASE_URL=
|
||||
|
||||
# You only need this environment variable set if you want to use OpenAI Like models
|
||||
OPENAI_LIKE_API_BASE_URL=
|
||||
|
||||
# You only need this environment variable set if you want to use Together AI models
|
||||
TOGETHER_API_BASE_URL=
|
||||
|
||||
# You only need this environment variable set if you want to use DeepSeek models through their API
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
# Get your OpenAI Like API Key
|
||||
OPENAI_LIKE_API_KEY=
|
||||
|
||||
# Get your Together API Key
|
||||
TOGETHER_API_KEY=
|
||||
|
||||
# You only need this environment variable set if you want to use Hyperbolic models
|
||||
#Get your Hyperbolics API Key at https://app.hyperbolic.xyz/settings
|
||||
#baseURL="https://api.hyperbolic.xyz/v1/chat/completions"
|
||||
HYPERBOLIC_API_KEY=
|
||||
HYPERBOLIC_API_BASE_URL=
|
||||
|
||||
# Get your Mistral API Key by following these instructions -
|
||||
# https://console.mistral.ai/api-keys/
|
||||
# You only need this environment variable set if you want to use Mistral models
|
||||
MISTRAL_API_KEY=
|
||||
|
||||
# Get the Cohere Api key by following these instructions -
|
||||
# https://dashboard.cohere.com/api-keys
|
||||
# You only need this environment variable set if you want to use Cohere models
|
||||
COHERE_API_KEY=
|
||||
|
||||
# Get LMStudio Base URL from LM Studio Developer Console
|
||||
# Make sure to enable CORS
|
||||
# DONT USE http://localhost:1234 due to IPV6 issues
|
||||
# Example: http://127.0.0.1:1234
|
||||
LMSTUDIO_API_BASE_URL=
|
||||
|
||||
# Get your xAI API key
|
||||
# https://x.ai/api
|
||||
# You only need this environment variable set if you want to use xAI models
|
||||
XAI_API_KEY=
|
||||
|
||||
# Get your Perplexity API Key here -
|
||||
# https://www.perplexity.ai/settings/api
|
||||
# You only need this environment variable set if you want to use Perplexity models
|
||||
PERPLEXITY_API_KEY=
|
||||
|
||||
# Get your AWS configuration
|
||||
# https://console.aws.amazon.com/iam/home
|
||||
# The JSON should include the following keys:
|
||||
# - region: The AWS region where Bedrock is available.
|
||||
# - accessKeyId: Your AWS access key ID.
|
||||
# - secretAccessKey: Your AWS secret access key.
|
||||
# - sessionToken (optional): Temporary session token if using an IAM role or temporary credentials.
|
||||
# Example JSON:
|
||||
# {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey", "sessionToken": "yourSessionToken"}
|
||||
AWS_BEDROCK_CONFIG=
|
||||
|
||||
# 是否开启文件日志
|
||||
USAGE_LOG_FILE=false
|
||||
# Include this environment variable if you want more logging for debugging locally
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Example Context Values for qwen2.5-coder:32b
|
||||
#
|
||||
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
|
||||
# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM
|
||||
# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM
|
||||
# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM
|
||||
DEFAULT_NUM_CTX=
|
||||
|
||||
# Get your Serper API Key https://serper.dev/
|
||||
SERPER_API_KEY=
|
||||
|
||||
# Get your Weather API Key https://www.weatherapi.com/my/
|
||||
WEATHER_API_KEY=
|
||||
|
||||
# LLM Configuration Options
|
||||
|
||||
# Default LLM provider to use (e.g.,OpenAILike,OpenAI, Anthropic, Mistral)
|
||||
LLM_DEFAULT_PROVIDER=
|
||||
|
||||
# 生成页面所使用的 MODEL(应该与 LLM_DEFAULT_PROVIDER 相对应)
|
||||
LLM_DEFAULT_MODEL=
|
||||
|
||||
# 用于辅助页面生成所使用的 MODEL,例如总结和预分析。(应该与 LLM_DEFAULT_PROVIDER 相对应)
|
||||
LLM_MINOR_MODEL=
|
||||
|
||||
# Comma-separated list of enabled providers (empty means all providers)
|
||||
# Example: OpenAILike,OpenAI,Anthropic,Mistral
|
||||
LLM_ENABLED_PROVIDERS=
|
||||
|
||||
# Logto 集成所需环境变量
|
||||
# Logto 地址
|
||||
LOGTO_ENDPOINT=
|
||||
# Logto 应用 ID
|
||||
LOGTO_APP_ID=
|
||||
# Logto 应用密钥
|
||||
LOGTO_APP_SECRET=
|
||||
# 应用基础 URL,根据实际部署环境修改
|
||||
LOGTO_BASE_URL=http://localhost:5173
|
||||
# 随机任意的 36 位字符串,用于加密 Logto 的 cookie。
|
||||
LOGTO_COOKIE_SECRET=
|
||||
# 是否在开发环境中启用 Logto 认证,设置为 false 则在开发环境不强制认证
|
||||
LOGTO_ENABLE_DEV=false
|
||||
# 运行环境,与 NODE_ENV 有所不同, NODE_ENV 在打包时就已确定,而此变量用于某些功能在不同环境下的开放
|
||||
# development | production | test
|
||||
OPERATING_ENV=production
|
||||
|
||||
# 资源文件存储位置
|
||||
STORAGE_DIR=/public/uploads
|
||||
# 附件上传的最大大小
|
||||
MAX_UPLOAD_SIZE_MB=5
|
||||
|
||||
|
||||
POSTGRES_PASSWORD=p0stgr3s
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
DATABASE_URL="postgresql://upage:${POSTGRES_PASSWORD}@localhost:5433/upage?schema=upage_schema"
|
||||
|
||||
75
.github/actions/docker-buildx-push/action.yaml
vendored
Normal file
75
.github/actions/docker-buildx-push/action.yaml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: "Docker buildx and push"
|
||||
description: "Buildx and push the Docker image."
|
||||
|
||||
inputs:
|
||||
ghcr-token:
|
||||
description: Token of current GitHub account in GitHub container registry.
|
||||
required: false
|
||||
default: ""
|
||||
dockerhub-user:
|
||||
description: "User name for the DockerHub account"
|
||||
required: false
|
||||
default: ""
|
||||
dockerhub-token:
|
||||
description: Token for the DockerHub account
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
description: Should push the docker image or not.
|
||||
required: false
|
||||
default: "false"
|
||||
platforms:
|
||||
description: Target platforms for building image
|
||||
required: false
|
||||
default: "linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x"
|
||||
image-name:
|
||||
description: The basic name of docker.
|
||||
required: false
|
||||
default: "upage"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Docker meta for UPage
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }}
|
||||
halohub/${{ inputs.image-name }}
|
||||
tags: |
|
||||
type=schedule,pattern=nightly-{{date 'YYYYMMDD'}},enabled=${{ github.event_name == 'schedule' }}
|
||||
type=ref,event=branch,enabled=${{ github.event_name == 'push' }}
|
||||
type=ref,event=pr,enabled=${{ github.event_name == 'pull_request' }}
|
||||
type=semver,pattern={{major}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{ version }}
|
||||
type=sha,enabled=${{ github.event_name == 'push' }}
|
||||
flavor: |
|
||||
latest=false
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
if: inputs.ghcr-token != '' && github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ inputs.ghcr-token }}
|
||||
- name: Login to DockerHub
|
||||
if: inputs.dockerhub-token != '' && github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-user }}
|
||||
password: ${{ inputs.dockerhub-token }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ inputs.platforms }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
push: ${{ (inputs.ghcr-token != '' || inputs.dockerhub-token != '') && inputs.push == 'true' }}
|
||||
32
.github/actions/setup-and-build/action.yaml
vendored
Normal file
32
.github/actions/setup-and-build/action.yaml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Setup and Build
|
||||
description: Generic setup action
|
||||
inputs:
|
||||
pnpm-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '9.4.0'
|
||||
node-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '20.15.1'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ inputs.pnpm-version }}
|
||||
run_install: false
|
||||
|
||||
- name: Set Node.js version to ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies and build project
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
27
.github/workflows/ci.yaml
vendored
Normal file
27
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Run check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
39
.github/workflows/docker.yaml
vendored
Normal file
39
.github/workflows/docker.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**"
|
||||
- "!**.md"
|
||||
- '!docs/**'
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
concurrency:
|
||||
group: ${{github.workflow}} - ${{github.ref}}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
- name: Docker Buildx and Push
|
||||
uses: ./.github/actions/docker-buildx-push
|
||||
with:
|
||||
image-name: ${{ github.event_name == 'release' && 'upage' || 'upage-dev' }}
|
||||
ghcr-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
dockerhub-user: ${{ secrets.DOCKER_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKER_TOKEN }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
35
.github/workflows/docs.yaml
vendored
Normal file
35
.github/workflows/docs.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Docs CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**' # This will only trigger the workflow when files in docs directory change
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
build_docs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./docs
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
|
||||
- run: pip install mkdocs-material
|
||||
- run: mkdocs gh-deploy --force
|
||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.cursor
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/.history
|
||||
/.cache
|
||||
/build
|
||||
functions/build/
|
||||
.env.local
|
||||
/.env
|
||||
.dev.vars
|
||||
*.vars
|
||||
_worker.bundle
|
||||
|
||||
Modelfile
|
||||
modelfiles
|
||||
|
||||
# docs ignore
|
||||
site
|
||||
|
||||
# commit file ignore
|
||||
app/commit.json
|
||||
changelogUI.md
|
||||
docs/instructions/Roadmap.md
|
||||
.qodo
|
||||
|
||||
|
||||
# prisma
|
||||
generated/prisma/
|
||||
|
||||
# mock
|
||||
mock
|
||||
|
||||
# storage
|
||||
/public/uploads
|
||||
32
.husky/pre-commit
Normal file
32
.husky/pre-commit
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
|
||||
|
||||
# Load NVM if available (useful for managing Node.js versions)
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Ensure `pnpm` is available
|
||||
echo "Checking if pnpm is available..."
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "❌ pnpm not found! Please ensure pnpm is installed and available in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run typecheck
|
||||
echo "Running typecheck..."
|
||||
if ! pnpm typecheck; then
|
||||
echo "❌ Type checking failed! Please review TypeScript types."
|
||||
echo "Once you're done, don't forget to add your changes to the commit! 🚀"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run check
|
||||
echo "Running check..."
|
||||
if ! pnpm check:stage; then
|
||||
echo "❌ Check failed! Run 'pnpm check:stage' to fix the easy issues."
|
||||
echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "👍 All checks passed! Committing changes..."
|
||||
18
.vscode/settings.json
vendored
Normal file
18
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "never",
|
||||
"source.fixAll": "never",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
83
Dockerfile
Normal file
83
Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
||||
FROM node:20.18.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++ bash
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
ENV HUSKY=0
|
||||
|
||||
RUN npm install -g pnpm && pnpm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm run build
|
||||
|
||||
FROM node:20.18.0-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++ bash
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
ENV HUSKY=0
|
||||
|
||||
RUN npm install -g pnpm && pnpm install --prod
|
||||
|
||||
COPY prisma ./prisma
|
||||
|
||||
RUN pnpm prisma generate
|
||||
|
||||
FROM node:20.18.0-alpine AS upage-ai-production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ARG LOG_LEVEL=debug
|
||||
ARG PORT=3000
|
||||
ARG LLM_DEFAULT_PROVIDER
|
||||
ARG LLM_DEFAULT_MODEL
|
||||
ARG LLM_ENABLED_PROVIDERS
|
||||
ARG DEFAULT_NUM_CTX
|
||||
ARG OLLAMA_API_BASE_URL
|
||||
ARG TOGETHER_API_BASE_URL
|
||||
ARG LOGTO_ENDPOINT
|
||||
ARG LOGTO_APP_ID
|
||||
ARG LOGTO_BASE_URL
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=${PORT} \
|
||||
LOG_LEVEL=${LOG_LEVEL} \
|
||||
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} \
|
||||
LLM_DEFAULT_PROVIDER=${LLM_DEFAULT_PROVIDER} \
|
||||
LLM_DEFAULT_MODEL=${LLM_DEFAULT_MODEL} \
|
||||
LLM_ENABLED_PROVIDERS=${LLM_ENABLED_PROVIDERS} \
|
||||
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
|
||||
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
|
||||
LOGTO_ENDPOINT=${LOGTO_ENDPOINT} \
|
||||
LOGTO_APP_ID=${LOGTO_APP_ID} \
|
||||
LOGTO_BASE_URL=${LOGTO_BASE_URL} \
|
||||
RUNNING_IN_DOCKER=true \
|
||||
STORAGE_DIR=/app/storage
|
||||
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/server.mjs ./
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/prisma ./prisma
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD [ "pnpm", "run", "start" ]
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
<h1 align="center">UPage</h1>
|
||||
<h3 align="center">基于人工智能的可视化网页构建平台</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/halo-dev/upage/releases"><img alt="GitHub release" src="https://img.shields.io/github/release/halo-dev/upage.svg?style=flat-square&include_prereleases" /></a>
|
||||
<a href="https://github.com/halo-dev/upage/commits"><img alt="GitHub last commit" src="https://img.shields.io/github/last-commit/halo-dev/upage.svg?style=flat-square" /></a>
|
||||
<a href="https://github.com/halo-dev/upage/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/halo-dev/upage/halo.yaml?branch=main&style=flat-square" /></a>
|
||||
</p>
|
||||
|
||||
------------------------------
|
||||
|
||||
UPage 是一款基于人工智能的可视化网页构建平台,支持多种 AI 提供商集成,快速实现定制化网页。
|
||||
|
||||
------------------------------
|
||||
|
||||
特别感谢 [bolt.diy](https://github.com/stackblitz-labs/bolt.diy) 项目,UPage 的实现基于该项目的代码结构。
|
||||
|
||||
------------------------------
|
||||
|
||||
## 快速开始
|
||||
|
||||
UPage 提供基于 Docker 的部署方案,可以使用以下脚本进行快速部署:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name upage \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-e LLM_DEFAULT_PROVIDER=OpenAILike \
|
||||
-e OPENAI_LIKE_API_KEY=your-openai-like-api-key \
|
||||
-e LLM_DEFAULT_MODEL=your-default-model \
|
||||
-e LLM_MINOR_MODEL=your-minor-model \
|
||||
-v ./logs:/app/logs \
|
||||
-v ./storage:/app/storage \
|
||||
ghcr.io/halo-dev/upage:latest
|
||||
```
|
||||
140
app/components/@settings/core/AvatarDropdown.tsx
Normal file
140
app/components/@settings/core/AvatarDropdown.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '~/lib/hooks/useAuth';
|
||||
import type { TabType } from './types';
|
||||
|
||||
interface AvatarDropdownProps {
|
||||
onSelectTab: (tab: TabType) => void;
|
||||
}
|
||||
|
||||
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
||||
const { userInfo, isAuthenticated } = useAuth();
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return 'Guest User';
|
||||
}
|
||||
|
||||
return userInfo.name || userInfo.username;
|
||||
}, [userInfo]);
|
||||
|
||||
const contactInfo = useMemo(() => {
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userInfo.phone_number) {
|
||||
return `+${userInfo.phone_number}`;
|
||||
}
|
||||
|
||||
return userInfo.email;
|
||||
}, [userInfo]);
|
||||
|
||||
const avatarUrl = isAuthenticated && userInfo?.picture ? userInfo.picture : '';
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<motion.button
|
||||
className="size-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className="size-full rounded-full object-cover"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
||||
<div className="i-ph:question size-6" />
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
'min-w-[240px] z-[250]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-gray-200/50 dark:border-gray-800/50',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'py-1',
|
||||
)}
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'px-4 py-3 flex items-center gap-3',
|
||||
'border-b border-gray-200/50 dark:border-gray-800/50',
|
||||
)}
|
||||
>
|
||||
<div className="size-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className={classNames('size-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
||||
<span className="relative -top-0.5">?</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">{displayName}</div>
|
||||
{isAuthenticated && userInfo?.email && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{contactInfo}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
)}
|
||||
onClick={() => onSelectTab('settings')}
|
||||
>
|
||||
<div className="i-ph:gear-six size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
)}
|
||||
onClick={() => onSelectTab('task-manager')}
|
||||
>
|
||||
<div className="i-ph:activity size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Task Manager
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
329
app/components/@settings/core/ControlPanel.tsx
Normal file
329
app/components/@settings/core/ControlPanel.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import classNames from 'classnames';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { TabTile } from '~/components/@settings/core/TabTile';
|
||||
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
||||
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
||||
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
||||
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
||||
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
|
||||
import { useNotifications } from '~/lib/hooks/useNotifications';
|
||||
import { profileStore } from '~/lib/stores/profile';
|
||||
import { resetTabConfiguration, tabConfigurationStore } from '~/lib/stores/settings';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { AvatarDropdown } from './AvatarDropdown';
|
||||
import { DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS } from './constants';
|
||||
import type { Profile, TabType, TabVisibilityConfig } from './types';
|
||||
|
||||
interface ControlPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TabWithDevType extends TabVisibilityConfig {
|
||||
isExtraDevTab?: boolean;
|
||||
}
|
||||
|
||||
interface ExtendedTabConfig extends TabVisibilityConfig {
|
||||
isExtraDevTab?: boolean;
|
||||
}
|
||||
|
||||
interface BaseTabConfig {
|
||||
id: TabType;
|
||||
visible: boolean;
|
||||
window: 'user' | 'developer';
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
||||
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
||||
|
||||
// Store values
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
const profile = useStore(profileStore) as Profile;
|
||||
|
||||
// Status hooks
|
||||
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
|
||||
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
||||
|
||||
// Memoize the base tab configurations to avoid recalculation
|
||||
const baseTabConfig = useMemo(() => {
|
||||
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
|
||||
}, []);
|
||||
|
||||
// Add visibleTabs logic using useMemo with optimized calculations
|
||||
const visibleTabs = useMemo(() => {
|
||||
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
||||
logger.warn('Invalid tab configuration, resetting to defaults');
|
||||
resetTabConfiguration();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenTabs = new Set<TabType>();
|
||||
const devTabs: ExtendedTabConfig[] = [];
|
||||
|
||||
// Process tabs in order of priority: developer, user, default
|
||||
const processTab = (tab: BaseTabConfig) => {
|
||||
if (!seenTabs.has(tab.id)) {
|
||||
seenTabs.add(tab.id);
|
||||
devTabs.push({
|
||||
id: tab.id,
|
||||
visible: true,
|
||||
window: 'developer',
|
||||
order: tab.order || devTabs.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Process tabs in priority order
|
||||
tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
|
||||
return devTabs.sort((a, b) => a.order - b.order);
|
||||
}, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]);
|
||||
|
||||
// Optimize animation performance with layout animations
|
||||
const gridLayoutVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 0.6,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Reset to default view when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset when closing
|
||||
setActiveTab(null);
|
||||
setLoadingTab(null);
|
||||
} else {
|
||||
// When opening, set to null to show the main view
|
||||
setActiveTab(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle closing
|
||||
const handleClose = () => {
|
||||
setActiveTab(null);
|
||||
setLoadingTab(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handlers
|
||||
const handleBack = () => {
|
||||
setActiveTab(null);
|
||||
};
|
||||
|
||||
const getTabComponent = (tabId: TabType) => {
|
||||
switch (tabId) {
|
||||
case 'settings':
|
||||
return <SettingsTab />;
|
||||
case 'notifications':
|
||||
return <NotificationsTab />;
|
||||
case 'debug':
|
||||
return <DebugTab />;
|
||||
case 'event-logs':
|
||||
return <EventLogsTab />;
|
||||
case 'task-manager':
|
||||
return <TaskManagerTab />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTabUpdateStatus = (tabId: TabType): boolean => {
|
||||
switch (tabId) {
|
||||
case 'notifications':
|
||||
return hasUnreadNotifications;
|
||||
case 'debug':
|
||||
return hasActiveWarnings;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = (tabId: TabType): string => {
|
||||
switch (tabId) {
|
||||
case 'notifications':
|
||||
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
|
||||
case 'debug': {
|
||||
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
|
||||
const errors = activeIssues.filter((i) => i.type === 'error').length;
|
||||
|
||||
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tabId: TabType) => {
|
||||
setLoadingTab(tabId);
|
||||
setActiveTab(tabId);
|
||||
|
||||
// Acknowledge notifications based on tab
|
||||
switch (tabId) {
|
||||
case 'notifications':
|
||||
markAllAsRead();
|
||||
break;
|
||||
case 'debug':
|
||||
acknowledgeAllIssues();
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear loading state after a delay
|
||||
setTimeout(() => setLoadingTab(null), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
|
||||
<RadixDialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'w-[1200px] h-[90vh]',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'rounded-2xl shadow-2xl',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'flex flex-col overflow-hidden',
|
||||
'relative',
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
||||
<BackgroundRays />
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-4">
|
||||
{activeTab && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:arrow-left size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Avatar and Dropdown */}
|
||||
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
||||
<AvatarDropdown onSelectTab={handleTabClick} />
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex-1',
|
||||
'overflow-y-auto',
|
||||
'hover:overflow-y-auto',
|
||||
'scrollbar scrollbar-w-2',
|
||||
'scrollbar-track-transparent',
|
||||
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
||||
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
||||
'will-change-scroll',
|
||||
'touch-auto',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
key={activeTab || 'home'}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-6"
|
||||
>
|
||||
{activeTab ? (
|
||||
getTabComponent(activeTab)
|
||||
) : (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
||||
variants={gridLayoutVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
||||
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
||||
<TabTile
|
||||
tab={tab}
|
||||
onClick={() => handleTabClick(tab.id as TabType)}
|
||||
isActive={activeTab === tab.id}
|
||||
hasUpdate={getTabUpdateStatus(tab.id)}
|
||||
statusMessage={getStatusMessage(tab.id)}
|
||||
description={TAB_DESCRIPTIONS[tab.id]}
|
||||
isLoading={loadingTab === tab.id}
|
||||
className="h-full relative"
|
||||
></TabTile>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</div>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
};
|
||||
135
app/components/@settings/core/TabTile.tsx
Normal file
135
app/components/@settings/core/TabTile.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TAB_ICONS, TAB_LABELS } from '~/components/@settings/core/constants';
|
||||
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
||||
|
||||
interface TabTileProps {
|
||||
tab: TabVisibilityConfig;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
hasUpdate?: boolean;
|
||||
statusMessage?: string;
|
||||
description?: string;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabTile: React.FC<TabTileProps> = ({
|
||||
tab,
|
||||
onClick,
|
||||
isActive,
|
||||
hasUpdate,
|
||||
statusMessage,
|
||||
description,
|
||||
isLoading,
|
||||
className,
|
||||
children,
|
||||
}: TabTileProps) => {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<motion.div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'relative flex flex-col items-center p-6 rounded-xl',
|
||||
'size-full min-h-[160px]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'group',
|
||||
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
||||
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
||||
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
||||
isLoading ? 'cursor-wait opacity-70' : '',
|
||||
className || '',
|
||||
)}
|
||||
>
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'relative',
|
||||
'size-14',
|
||||
'flex items-center justify-center',
|
||||
'rounded-xl',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'ring-1 ring-gray-200 dark:ring-gray-700',
|
||||
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
||||
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
||||
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
TAB_ICONS[tab.id],
|
||||
'size-8',
|
||||
'text-gray-600 dark:text-gray-300',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex flex-col items-center mt-5 w-full">
|
||||
<h3
|
||||
className={classNames(
|
||||
'text-[15px] font-medium leading-snug mb-2',
|
||||
'text-gray-700 dark:text-gray-200',
|
||||
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
>
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={classNames(
|
||||
'text-[13px] leading-relaxed',
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
'max-w-[85%]',
|
||||
'text-center',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
||||
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Indicator with Tooltip */}
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<div className="absolute top-4 right-4 size-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg',
|
||||
'bg-[#18181B] text-white',
|
||||
'text-sm font-medium',
|
||||
'select-none',
|
||||
'z-[100]',
|
||||
)}
|
||||
side="top"
|
||||
sideOffset={5}
|
||||
>
|
||||
{statusMessage}
|
||||
<Tooltip.Arrow className="fill-[#18181B]" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Children (e.g. Beta Label) */}
|
||||
{children}
|
||||
</motion.div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
};
|
||||
49
app/components/@settings/core/constants.ts
Normal file
49
app/components/@settings/core/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { TabType } from './types';
|
||||
|
||||
export const TAB_ICONS: Record<TabType, string> = {
|
||||
profile: 'i-ph:user-circle-fill',
|
||||
settings: 'i-ph:gear-six-fill',
|
||||
notifications: 'i-ph:bell-fill',
|
||||
debug: 'i-ph:bug-fill',
|
||||
'event-logs': 'i-ph:list-bullets-fill',
|
||||
'task-manager': 'i-ph:chart-line-fill',
|
||||
'tab-management': 'i-ph:squares-four-fill',
|
||||
};
|
||||
|
||||
export const TAB_LABELS: Record<TabType, string> = {
|
||||
profile: 'Profile',
|
||||
settings: 'Settings',
|
||||
notifications: 'Notifications',
|
||||
debug: 'Debug',
|
||||
'event-logs': 'Event Logs',
|
||||
'task-manager': 'Task Manager',
|
||||
'tab-management': 'Tab Management',
|
||||
};
|
||||
|
||||
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
||||
profile: 'Manage your profile and account settings',
|
||||
settings: 'Configure application preferences',
|
||||
notifications: 'View and manage your notifications',
|
||||
debug: 'Debug tools and system information',
|
||||
'event-logs': 'View system events and logs',
|
||||
'task-manager': 'Monitor system resources and processes',
|
||||
'tab-management': 'Configure visible tabs and their order',
|
||||
};
|
||||
|
||||
export const DEFAULT_TAB_CONFIG = [
|
||||
{ id: 'notifications', visible: true, window: 'user' as const, order: 5 },
|
||||
{ id: 'event-logs', visible: true, window: 'user' as const, order: 6 },
|
||||
|
||||
{ id: 'settings', visible: false, window: 'user' as const, order: 8 },
|
||||
{ id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
|
||||
|
||||
// User Window Tabs (Hidden, controlled by TaskManagerTab)
|
||||
{ id: 'debug', visible: false, window: 'user' as const, order: 11 },
|
||||
|
||||
// Developer Window Tabs (All visible by default)
|
||||
{ id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
|
||||
{ id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
|
||||
{ id: 'settings', visible: true, window: 'developer' as const, order: 8 },
|
||||
{ id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
|
||||
{ id: 'debug', visible: true, window: 'developer' as const, order: 11 },
|
||||
];
|
||||
77
app/components/@settings/core/types.ts
Normal file
77
app/components/@settings/core/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
|
||||
|
||||
export type TabType =
|
||||
| 'profile'
|
||||
| 'settings'
|
||||
| 'notifications'
|
||||
| 'debug'
|
||||
| 'event-logs'
|
||||
| 'task-manager'
|
||||
| 'tab-management';
|
||||
|
||||
export type WindowType = 'user' | 'developer';
|
||||
|
||||
export interface UserProfile {
|
||||
nickname: any;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
notifications: boolean;
|
||||
password?: string;
|
||||
bio?: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface TabVisibilityConfig {
|
||||
id: TabType;
|
||||
visible: boolean;
|
||||
window: WindowType;
|
||||
order: number;
|
||||
isExtraDevTab?: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface DevTabConfig extends TabVisibilityConfig {
|
||||
window: 'developer';
|
||||
}
|
||||
|
||||
export interface UserTabConfig extends TabVisibilityConfig {
|
||||
window: 'user';
|
||||
}
|
||||
|
||||
export interface TabWindowConfig {
|
||||
userTabs: UserTabConfig[];
|
||||
developerTabs: DevTabConfig[];
|
||||
}
|
||||
|
||||
export const categoryLabels: Record<SettingCategory, string> = {
|
||||
profile: 'Profile & Account',
|
||||
file_sharing: 'File Sharing',
|
||||
connectivity: 'Connectivity',
|
||||
system: 'System',
|
||||
services: 'Services',
|
||||
preferences: 'Preferences',
|
||||
};
|
||||
|
||||
export const categoryIcons: Record<SettingCategory, string> = {
|
||||
profile: 'i-ph:user-circle',
|
||||
file_sharing: 'i-ph:folder-simple',
|
||||
connectivity: 'i-ph:wifi-high',
|
||||
system: 'i-ph:gear',
|
||||
services: 'i-ph:cube',
|
||||
preferences: 'i-ph:sliders',
|
||||
};
|
||||
|
||||
export interface Profile {
|
||||
username?: string;
|
||||
bio?: string;
|
||||
avatar?: string;
|
||||
preferences?: {
|
||||
notifications?: boolean;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
}
|
||||
10
app/components/@settings/index.ts
Normal file
10
app/components/@settings/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Core exports
|
||||
export { ControlPanel } from './core/ControlPanel';
|
||||
// Constants
|
||||
export { DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS, TAB_LABELS } from './core/constants';
|
||||
// Shared components
|
||||
export { TabTile } from './core/TabTile';
|
||||
export type { TabType, TabVisibilityConfig } from './core/types';
|
||||
export * from './utils/animations';
|
||||
// Utils
|
||||
export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
|
||||
1919
app/components/@settings/tabs/debug/DebugTab.tsx
Normal file
1919
app/components/@settings/tabs/debug/DebugTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1013
app/components/@settings/tabs/event-logs/EventLogsTab.tsx
Normal file
1013
app/components/@settings/tabs/event-logs/EventLogsTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
300
app/components/@settings/tabs/notifications/NotificationsTab.tsx
Normal file
300
app/components/@settings/tabs/notifications/NotificationsTab.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import classNames from 'classnames';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
interface NotificationDetails {
|
||||
type?: string;
|
||||
message?: string;
|
||||
currentVersion?: string;
|
||||
latestVersion?: string;
|
||||
branch?: string;
|
||||
updateUrl?: string;
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
|
||||
|
||||
const NotificationsTab = () => {
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
const logs = useStore(logStore.logs);
|
||||
|
||||
useEffect(() => {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const duration = performance.now() - startTime;
|
||||
logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClearNotifications = () => {
|
||||
const count = Object.keys(logs).length;
|
||||
logStore.logInfo('Cleared notifications', {
|
||||
type: 'notification_clear',
|
||||
message: `Cleared ${count} notifications`,
|
||||
clearedCount: count,
|
||||
component: 'notifications',
|
||||
});
|
||||
logStore.clearLogs();
|
||||
};
|
||||
|
||||
const handleUpdateAction = (updateUrl: string) => {
|
||||
logStore.logInfo('Update link clicked', {
|
||||
type: 'update_click',
|
||||
message: 'User clicked update link',
|
||||
updateUrl,
|
||||
component: 'notifications',
|
||||
});
|
||||
window.open(updateUrl, '_blank');
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilter: FilterType) => {
|
||||
logStore.logInfo('Notification filter changed', {
|
||||
type: 'filter_change',
|
||||
message: `Filter changed to ${newFilter}`,
|
||||
previousFilter: filter,
|
||||
newFilter,
|
||||
component: 'notifications',
|
||||
});
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
const filteredLogs = Object.values(logs)
|
||||
.filter((log) => {
|
||||
if (filter === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filter === 'update') {
|
||||
return log.details?.type === 'update';
|
||||
}
|
||||
|
||||
if (filter === 'system') {
|
||||
return log.category === 'system';
|
||||
}
|
||||
|
||||
if (filter === 'provider') {
|
||||
return log.category === 'provider';
|
||||
}
|
||||
|
||||
if (filter === 'network') {
|
||||
return log.category === 'network';
|
||||
}
|
||||
|
||||
return log.level === filter;
|
||||
})
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
const getNotificationStyle = (level: string, type?: string) => {
|
||||
if (type === 'update') {
|
||||
return {
|
||||
icon: 'i-ph:arrow-circle-up',
|
||||
color: 'text-purple-500 dark:text-purple-400',
|
||||
bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
||||
};
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return {
|
||||
icon: 'i-ph:warning-circle',
|
||||
color: 'text-red-500 dark:text-red-400',
|
||||
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
icon: 'i-ph:warning',
|
||||
color: 'text-yellow-500 dark:text-yellow-400',
|
||||
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: 'i-ph:info',
|
||||
color: 'text-blue-500 dark:text-blue-400',
|
||||
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'i-ph:bell',
|
||||
color: 'text-gray-500 dark:text-gray-400',
|
||||
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const renderNotificationDetails = (details: NotificationDetails) => {
|
||||
if (details.type === 'update') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p>
|
||||
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
<p>Current Version: {details.currentVersion}</p>
|
||||
<p>Latest Version: {details.latestVersion}</p>
|
||||
<p>Branch: {details.branch}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
|
||||
className={classNames(
|
||||
'mt-2 inline-flex items-center gap-2',
|
||||
'rounded-lg px-3 py-1.5',
|
||||
'text-sm font-medium',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'text-gray-900 dark:text-white',
|
||||
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:git-branch text-lg" />
|
||||
View Changes
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
|
||||
};
|
||||
|
||||
const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
|
||||
{ id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
|
||||
{ id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
|
||||
{ id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
|
||||
{ id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
|
||||
{ id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
|
||||
{ id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
|
||||
{ id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
|
||||
{ id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center gap-2',
|
||||
'rounded-lg px-3 py-1.5',
|
||||
'text-sm text-gray-900 dark:text-white',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames('text-lg', filterOptions.find((opt) => opt.id === filter)?.icon || 'i-ph:funnel')}
|
||||
style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
|
||||
/>
|
||||
{filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
|
||||
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
sideOffset={5}
|
||||
align="start"
|
||||
side="bottom"
|
||||
>
|
||||
{filterOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.id}
|
||||
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
|
||||
onClick={() => handleFilterChange(option.id)}
|
||||
>
|
||||
<div className="mr-3 flex size-5 items-center justify-center">
|
||||
<div
|
||||
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
||||
style={{ color: option.color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<button
|
||||
onClick={handleClearNotifications}
|
||||
className={classNames(
|
||||
'group flex items-center gap-2',
|
||||
'rounded-lg px-3 py-1.5',
|
||||
'text-sm text-gray-900 dark:text-white',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:trash text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={classNames(
|
||||
'flex flex-col items-center justify-center gap-4',
|
||||
'rounded-lg p-8 text-center',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Notifications</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
filteredLogs.map((log) => {
|
||||
const style = getNotificationStyle(log.level, log.details?.type);
|
||||
return (
|
||||
<motion.div
|
||||
key={log.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={classNames(
|
||||
'flex flex-col gap-2',
|
||||
'rounded-lg p-4',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
style.bg,
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={classNames('text-lg', style.icon, style.color)} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</h3>
|
||||
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Category: {log.category}
|
||||
{log.subCategory ? ` > ${log.subCategory}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
|
||||
</time>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsTab;
|
||||
215
app/components/@settings/tabs/settings/SettingsTab.tsx
Normal file
215
app/components/@settings/tabs/settings/SettingsTab.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { UserProfile } from '~/components/@settings/core/types';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import { isMac } from '~/utils/os';
|
||||
|
||||
// Helper to get modifier key symbols/text
|
||||
const getModifierSymbol = (modifier: string): string => {
|
||||
switch (modifier) {
|
||||
case 'meta':
|
||||
return isMac ? '⌘' : 'Win';
|
||||
case 'alt':
|
||||
return isMac ? '⌥' : 'Alt';
|
||||
case 'shift':
|
||||
return '⇧';
|
||||
default:
|
||||
return modifier;
|
||||
}
|
||||
};
|
||||
|
||||
export default function SettingsTab() {
|
||||
const [currentTimezone, setCurrentTimezone] = useState('');
|
||||
const [settings, setSettings] = useState<UserProfile>(() => {
|
||||
const saved = localStorage.getItem('upage_user_profile');
|
||||
return saved
|
||||
? JSON.parse(saved)
|
||||
: {
|
||||
notifications: true,
|
||||
language: 'en',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
}, []);
|
||||
|
||||
// Save settings automatically when they change
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Get existing profile data
|
||||
const existingProfile = JSON.parse(localStorage.getItem('upage_user_profile') || '{}');
|
||||
|
||||
// Merge with new settings
|
||||
const updatedProfile = {
|
||||
...existingProfile,
|
||||
notifications: settings.notifications,
|
||||
language: settings.language,
|
||||
timezone: settings.timezone,
|
||||
};
|
||||
|
||||
localStorage.setItem('upage_user_profile', JSON.stringify(updatedProfile));
|
||||
toast.success('Settings updated');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
toast.error('Failed to update settings');
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Language & Notifications */}
|
||||
<motion.div
|
||||
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="i-ph:palette-fill size-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary">Preferences</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="i-ph:translate-fill size-4 text-upage-elements-textSecondary" />
|
||||
<label className="block text-sm text-upage-elements-textSecondary">Language</label>
|
||||
</div>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'text-upage-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="i-ph:bell-fill size-4 text-upage-elements-textSecondary" />
|
||||
<label className="block text-sm text-upage-elements-textSecondary">Notifications</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-upage-elements-textSecondary">
|
||||
{settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={settings.notifications}
|
||||
onCheckedChange={(checked) => {
|
||||
// Update local state
|
||||
setSettings((prev) => ({ ...prev, notifications: checked }));
|
||||
|
||||
// Update localStorage immediately
|
||||
const existingProfile = JSON.parse(localStorage.getItem('upage_user_profile') || '{}');
|
||||
const updatedProfile = {
|
||||
...existingProfile,
|
||||
notifications: checked,
|
||||
};
|
||||
localStorage.setItem('upage_user_profile', JSON.stringify(updatedProfile));
|
||||
|
||||
// Dispatch storage event for other components
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'upage_user_profile',
|
||||
newValue: JSON.stringify(updatedProfile),
|
||||
}),
|
||||
);
|
||||
|
||||
toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Timezone */}
|
||||
<motion.div
|
||||
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="i-ph:clock-fill size-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary">Time Settings</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="i-ph:globe-fill size-4 text-upage-elements-textSecondary" />
|
||||
<label className="block text-sm text-upage-elements-textSecondary">Timezone</label>
|
||||
</div>
|
||||
<select
|
||||
value={settings.timezone}
|
||||
onChange={(e) => setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'text-upage-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
<option value={currentTimezone}>{currentTimezone}</option>
|
||||
</select>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Simplified Keyboard Shortcuts */}
|
||||
<motion.div
|
||||
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="i-ph:keyboard-fill size-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary">Keyboard Shortcuts</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-[#FAFAFA] dark:bg-[#1A1A1A]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-upage-elements-textPrimary">Toggle Theme</span>
|
||||
<span className="text-xs text-upage-elements-textSecondary">Switch between light and dark mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 text-xs font-semibold text-upage-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
||||
{getModifierSymbol('meta')}
|
||||
</kbd>
|
||||
<kbd className="px-2 py-1 text-xs font-semibold text-upage-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
||||
{getModifierSymbol('alt')}
|
||||
</kbd>
|
||||
<kbd className="px-2 py-1 text-xs font-semibold text-upage-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
||||
{getModifierSymbol('shift')}
|
||||
</kbd>
|
||||
<kbd className="px-2 py-1 text-xs font-semibold text-upage-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
||||
D
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1605
app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
Normal file
1605
app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
41
app/components/@settings/utils/animations.ts
Normal file
41
app/components/@settings/utils/animations.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Variants } from 'framer-motion';
|
||||
|
||||
export const fadeIn: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const slideIn: Variants = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
};
|
||||
|
||||
export const scaleIn: Variants = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.8 },
|
||||
};
|
||||
|
||||
export const tabAnimation: Variants = {
|
||||
initial: { opacity: 0, scale: 0.8, y: 20 },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.8, y: -20 },
|
||||
};
|
||||
|
||||
export const overlayAnimation: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const modalAnimation: Variants = {
|
||||
initial: { opacity: 0, scale: 0.95, y: 20 },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.95, y: 20 },
|
||||
};
|
||||
|
||||
export const transition = {
|
||||
duration: 0.2,
|
||||
};
|
||||
89
app/components/@settings/utils/tab-helpers.ts
Normal file
89
app/components/@settings/utils/tab-helpers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
|
||||
import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types';
|
||||
|
||||
export const getVisibleTabs = (
|
||||
tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] },
|
||||
isDeveloperMode: boolean,
|
||||
notificationsEnabled: boolean,
|
||||
): TabVisibilityConfig[] => {
|
||||
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
||||
console.warn('Invalid tab configuration, using defaults');
|
||||
return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
|
||||
}
|
||||
|
||||
// In developer mode, show ALL tabs without restrictions
|
||||
if (isDeveloperMode) {
|
||||
// Combine all unique tabs from both user and developer configurations
|
||||
const allTabs = new Set([
|
||||
...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
|
||||
...tabConfiguration.userTabs.map((tab) => tab.id),
|
||||
...(tabConfiguration.developerTabs || []).map((tab) => tab.id),
|
||||
'task-manager' as TabType, // Always include task-manager in developer mode
|
||||
]);
|
||||
|
||||
// Create a complete tab list with all tabs visible
|
||||
const devTabs = Array.from(allTabs).map((tabId) => {
|
||||
// Try to find existing configuration for this tab
|
||||
const existingTab =
|
||||
tabConfiguration.developerTabs?.find((t) => t.id === tabId) ||
|
||||
tabConfiguration.userTabs?.find((t) => t.id === tabId) ||
|
||||
DEFAULT_TAB_CONFIG.find((t) => t.id === tabId);
|
||||
|
||||
return {
|
||||
id: tabId as TabType,
|
||||
visible: true,
|
||||
window: 'developer' as const,
|
||||
order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
|
||||
} as TabVisibilityConfig;
|
||||
});
|
||||
|
||||
return devTabs.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// In user mode, only show visible user tabs
|
||||
return tabConfiguration.userTabs
|
||||
.filter((tab) => {
|
||||
if (!tab || typeof tab.id !== 'string') {
|
||||
console.warn('Invalid tab entry:', tab);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide notifications tab if notifications are disabled
|
||||
if (tab.id === 'notifications' && !notificationsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always show task-manager in user mode if it's configured as visible
|
||||
if (tab.id === 'task-manager') {
|
||||
return tab.visible;
|
||||
}
|
||||
|
||||
// Only show tabs that are explicitly visible and assigned to the user window
|
||||
return tab.visible && tab.window === 'user';
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
};
|
||||
|
||||
export const reorderTabs = (
|
||||
tabs: TabVisibilityConfig[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
): TabVisibilityConfig[] => {
|
||||
const result = Array.from(tabs);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
|
||||
// Update order property
|
||||
return result.map((tab, index) => ({
|
||||
...tab,
|
||||
order: index,
|
||||
}));
|
||||
};
|
||||
|
||||
export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
|
||||
return DEFAULT_TAB_CONFIG.map((tab) => ({
|
||||
...tab,
|
||||
visible: isDeveloperMode ? true : tab.window === 'user',
|
||||
window: isDeveloperMode ? 'developer' : tab.window,
|
||||
})) as TabVisibilityConfig[];
|
||||
};
|
||||
37
app/components/AuthErrorToast.client.tsx
Normal file
37
app/components/AuthErrorToast.client.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 定义错误响应的类型
|
||||
interface AuthErrorResponse {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证错误提示组件
|
||||
*
|
||||
* 这个组件检测认证过程中的错误,并使用 toast 显示错误消息
|
||||
*/
|
||||
export function AuthErrorToast() {
|
||||
// 使用 fetcher 从服务器获取错误信息
|
||||
const fetcher = useFetcher<AuthErrorResponse>();
|
||||
const [hasChecked, setHasChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只在组件首次加载时检查一次错误
|
||||
if (!hasChecked) {
|
||||
fetcher.load('/api/auth/check-error');
|
||||
setHasChecked(true);
|
||||
}
|
||||
}, [fetcher, hasChecked]);
|
||||
|
||||
useEffect(() => {
|
||||
// 当 fetcher 获取到数据时,如果有错误信息则显示
|
||||
if (fetcher.data?.errorMessage) {
|
||||
toast.error(fetcher.data.errorMessage);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
// 这是一个无形的组件,不渲染任何内容
|
||||
return null;
|
||||
}
|
||||
68
app/components/ErrorBoundary.tsx
Normal file
68
app/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
// 更新状态,下次渲染时显示降级 UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// 记录错误信息
|
||||
logger.error('组件错误边界捕获到错误:', { error, errorInfo });
|
||||
|
||||
// 显示错误提示
|
||||
toast.error(`组件发生错误: ${error.message}`);
|
||||
|
||||
// 调用可选的 onError 回调
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// 如果提供了自定义的降级 UI,则使用它
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// 默认的降级 UI
|
||||
return (
|
||||
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
||||
<h3 className="text-red-800 font-medium mb-2">组件加载失败</h3>
|
||||
<p className="text-red-600 text-sm mb-2">{this.state.error?.message || '发生了未知错误'}</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded text-sm hover:bg-red-200"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
70
app/components/auth/AuthButtons.tsx
Normal file
70
app/components/auth/AuthButtons.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { useAuth } from '~/lib/hooks/useAuth';
|
||||
|
||||
export function SignInButton({ className, children = '登录' }: { className?: string; children?: React.ReactNode }) {
|
||||
const { signIn, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSignIn = () => {
|
||||
signIn('/api/auth/callback');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleSignIn} className={className} disabled={isAuthenticated}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SignOutButton({ className, children = '登出' }: { className?: string; children?: React.ReactNode }) {
|
||||
const { signOut, isAuthenticated } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await signOut();
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleSignOut} className={className} disabled={!isAuthenticated || isLoading}>
|
||||
{isLoading ? '正在登出...' : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserAuthButton({
|
||||
className,
|
||||
signInText = '登录',
|
||||
signOutText = '登出',
|
||||
}: {
|
||||
className?: string;
|
||||
signInText?: React.ReactNode;
|
||||
signOutText?: React.ReactNode;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return isAuthenticated ? (
|
||||
<SignOutButton className={className}>{signOutText}</SignOutButton>
|
||||
) : (
|
||||
<SignInButton className={className}>{signInText}</SignInButton>
|
||||
);
|
||||
}
|
||||
31
app/components/auth/UserProfile.tsx
Normal file
31
app/components/auth/UserProfile.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useAuth } from '~/lib/hooks/useAuth';
|
||||
|
||||
export function UserProfile({ className }: { className?: string }) {
|
||||
const { isAuthenticated, userInfo, isLoading } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div className={className}>未登录</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={className}>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
return <div className={className}>无法获取用户信息</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{userInfo.picture && (
|
||||
<img
|
||||
src={userInfo.picture}
|
||||
alt={userInfo.name || userInfo.username || '用户头像'}
|
||||
className="size-10 rounded-full mb-2"
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold">{userInfo.name || userInfo.username || '用户'}</h3>
|
||||
{userInfo.email && <p className="text-sm text-gray-600">{userInfo.email}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
app/components/chat/Artifact.tsx
Normal file
227
app/components/chat/Artifact.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { computed } from 'nanostores';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { type BundledLanguage, type BundledTheme, createHighlighter, type HighlighterGeneric } from 'shiki';
|
||||
import type { ActionState } from '~/lib/runtime/action-runner';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
|
||||
const highlighterOptions = {
|
||||
langs: ['shell'],
|
||||
themes: ['light-plus', 'dark-plus'],
|
||||
};
|
||||
|
||||
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
|
||||
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.shellHighlighter = shellHighlighter;
|
||||
}
|
||||
|
||||
interface ArtifactProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
const userToggledActions = useRef(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [allActionFinished, setAllActionFinished] = useState(false);
|
||||
|
||||
const artifacts = useStore(webBuilderStore.chatStore.artifacts);
|
||||
const artifact = artifacts[messageId];
|
||||
|
||||
const actions = useStore(
|
||||
computed(artifact.runner.actions, (actions) => {
|
||||
return Object.values(actions);
|
||||
}),
|
||||
);
|
||||
|
||||
const toggleActions = () => {
|
||||
userToggledActions.current = true;
|
||||
setShowActions(!showActions);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const actionsMap = artifact.runner.actions.get();
|
||||
|
||||
Object.entries(actionsMap).forEach(([actionId, action]) => {
|
||||
if (action.status === 'running' || action.status === 'pending') {
|
||||
artifact.runner.actions.setKey(actionId, {
|
||||
...action,
|
||||
status: 'aborted',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (actions.length && !showActions && !userToggledActions.current) {
|
||||
setShowActions(true);
|
||||
}
|
||||
|
||||
if (actions.length !== 0 && artifact.type === 'bundled') {
|
||||
const finished = !actions.find((action) => action.status !== 'complete');
|
||||
|
||||
if (allActionFinished !== finished) {
|
||||
setAllActionFinished(finished);
|
||||
}
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
return (
|
||||
<div className="artifact border border-upage-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
|
||||
<div className="flex">
|
||||
<button
|
||||
className="flex items-stretch bg-upage-elements-artifacts-background hover:bg-upage-elements-artifacts-backgroundHover w-full overflow-hidden"
|
||||
onClick={() => {
|
||||
const showWorkbench = webBuilderStore.showWorkbench.get();
|
||||
webBuilderStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
{artifact.type == 'bundled' && (
|
||||
<>
|
||||
<div className="p-4">
|
||||
{allActionFinished ? (
|
||||
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
|
||||
) : (
|
||||
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-upage-elements-artifacts-borderColor w-[1px]" />
|
||||
</>
|
||||
)}
|
||||
<div className="px-5 p-3.5 w-full text-left">
|
||||
<div className="w-full text-upage-elements-textPrimary font-medium leading-5 text-sm">
|
||||
{artifact?.title}
|
||||
</div>
|
||||
<div className="w-full w-full text-upage-elements-textSecondary text-xs mt-0.5">点击打开 WebBuilder</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="bg-upage-elements-artifacts-borderColor w-[1px]" />
|
||||
<AnimatePresence>
|
||||
{actions.length && artifact.type !== 'bundled' && (
|
||||
<motion.button
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: 'auto' }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ duration: 0.15, ease: cubicEasingFn }}
|
||||
className="bg-upage-elements-artifacts-background hover:bg-upage-elements-artifacts-backgroundHover"
|
||||
onClick={toggleActions}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
|
||||
</div>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
|
||||
<motion.div
|
||||
className="actions"
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: '0px' }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="bg-upage-elements-artifacts-borderColor h-[1px]" />
|
||||
|
||||
<div className="p-5 text-left bg-upage-elements-actions-background">
|
||||
<ActionList actions={actions} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ActionListProps {
|
||||
actions: ActionState[];
|
||||
}
|
||||
|
||||
const actionVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
function openArtifactInWebBuilder(pageName: string, rootDomId: string) {
|
||||
if (webBuilderStore.currentView.get() !== 'code') {
|
||||
webBuilderStore.currentView.set('code');
|
||||
}
|
||||
webBuilderStore.setSelectedPage(pageName);
|
||||
webBuilderStore.editorStore.scrollToElement(rootDomId);
|
||||
}
|
||||
|
||||
const ActionList = memo(({ actions }: ActionListProps) => {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<ul className="list-none space-y-2.5">
|
||||
{actions.map((action, index) => {
|
||||
const { status } = action;
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
key={index}
|
||||
variants={actionVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<div className={classNames('text-lg', getIconColor(action.status))}>
|
||||
{status === 'running' ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg"></div>
|
||||
) : status === 'pending' ? (
|
||||
<div className="i-ph:circle-duotone"></div>
|
||||
) : status === 'complete' ? (
|
||||
<div className="i-ph:check"></div>
|
||||
) : status === 'failed' || status === 'aborted' ? (
|
||||
<div className="i-ph:x"></div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
{action.action === 'add' ? 'Create' : action.action === 'update' ? 'Update' : 'Delete'}{' '}
|
||||
<code
|
||||
className="bg-upage-ele ments-artifacts-inlineCode-background text-upage-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-upage-elements-item-contentAccent hover:underline cursor-pointer"
|
||||
onClick={() => openArtifactInWebBuilder(action.pageName, action.rootDomId)}
|
||||
>
|
||||
{action.id}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
function getIconColor(status: ActionState['status']) {
|
||||
switch (status) {
|
||||
case 'pending': {
|
||||
return 'text-upage-elements-textTertiary';
|
||||
}
|
||||
case 'running': {
|
||||
return 'text-upage-elements-loader-progress';
|
||||
}
|
||||
case 'complete': {
|
||||
return 'text-upage-elements-icon-success';
|
||||
}
|
||||
case 'aborted': {
|
||||
return 'text-upage-elements-textSecondary';
|
||||
}
|
||||
case 'failed': {
|
||||
return 'text-upage-elements-icon-error';
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/components/chat/AssistantMessage.tsx
Normal file
54
app/components/chat/AssistantMessage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { memo } from 'react';
|
||||
import Popover from '~/components/ui/Popover';
|
||||
import Tooltip from '~/components/ui/Tooltip';
|
||||
import type { ParsedUIMessage } from '~/lib/stores/ai-state';
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
export const AssistantMessage = memo(({ message }: { message: ParsedUIMessage }) => {
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
{message.parts.map((part) => {
|
||||
if (part.type === 'data-summary') {
|
||||
return (
|
||||
<div className="flex gap-2 items-center text-sm text-upage-elements-textSecondary mb-1.5">
|
||||
{part.data.summary && (
|
||||
<Tooltip tooltip="查看对话上下文" position="top">
|
||||
<div className="relative group">
|
||||
<Popover
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<button
|
||||
aria-label="Open context"
|
||||
className="i-ph:clipboard-text text-lg text-upage-elements-textSecondary cursor-pointer transition-all duration-200 ease-out"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{part.data.summary && (
|
||||
<div className="max-w-chat">
|
||||
<div className="summary flex flex-col">
|
||||
<div className="p-5 border border-upage-elements-borderColor rounded-md bg-upage-elements-background shadow-sm">
|
||||
<h2 className="text-lg font-medium text-upage-elements-textPrimary border-b border-upage-elements-borderColor pb-3 mb-4 flex items-center gap-2">
|
||||
<span className="i-ph:note-pencil"></span>
|
||||
摘要
|
||||
</h2>
|
||||
<div className="overflow-y-auto max-h-80 text-sm">
|
||||
<Markdown>{part.data.summary}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="context"></div>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{message.content && <Markdown html>{message.content}</Markdown>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
55
app/components/chat/BaseChat.module.scss
Normal file
55
app/components/chat/BaseChat.module.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.BaseChat {
|
||||
&[data-chat-visible='false'] {
|
||||
--workbench-inner-width: 100%;
|
||||
--workbench-left: 0;
|
||||
|
||||
.Chat {
|
||||
--at-apply: upage-ease-cubic-bezier;
|
||||
transition-property: transform, opacity;
|
||||
transition-duration: 0.3s;
|
||||
will-change: transform, opacity;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Chat {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.PromptEffectContainer {
|
||||
--prompt-container-offset: 50px;
|
||||
--prompt-line-stroke-width: 2px;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
inset: calc(var(--prompt-container-offset) / -2);
|
||||
width: calc(100% + var(--prompt-container-offset));
|
||||
height: calc(100% + var(--prompt-container-offset));
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.PromptEffectLine {
|
||||
width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
||||
height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
||||
x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
||||
y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
||||
rx: 10px;
|
||||
fill: transparent;
|
||||
stroke-width: var(--prompt-line-stroke-width);
|
||||
stroke: url(#line-gradient);
|
||||
stroke-dasharray: 20 30;
|
||||
stroke-dashoffset: 0;
|
||||
animation: borderRotate 18s linear infinite;
|
||||
filter: drop-shadow(0 0 5px rgba(180, 74, 255, 0.7));
|
||||
}
|
||||
|
||||
@keyframes borderRotate {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: -500;
|
||||
}
|
||||
}
|
||||
158
app/components/chat/Chat.client.tsx
Normal file
158
app/components/chat/Chat.client.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { useShortcuts, useSnapScroll } from '~/lib/hooks';
|
||||
import { useChatMessage } from '~/lib/hooks/useChatMessage';
|
||||
import { aiState, setChatId, setChatStarted } from '~/lib/stores/ai-state';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import type { ChatMessage, ChatWithMessages } from '~/types/chat';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { Menu } from '../sidebar/Menu.client';
|
||||
import { WebBuilder } from '../webbuilder/WebBuilder.client';
|
||||
import styles from './BaseChat.module.scss';
|
||||
import ChatAlert from './ChatAlert';
|
||||
import { ChatTextarea } from './ChatTextarea';
|
||||
import { ExamplePrompts } from './ExamplePrompts';
|
||||
import FilePreview from './FilePreview';
|
||||
import { Messages } from './Messages.client';
|
||||
import ProgressCompilation from './ProgressCompilation';
|
||||
import { ScreenshotStateManager } from './ScreenshotStateManager';
|
||||
|
||||
export type ImageData = {
|
||||
file: File;
|
||||
base64?: string;
|
||||
};
|
||||
|
||||
export function Chat() {
|
||||
renderLogger.trace('Chat');
|
||||
const { id, chat } = useLoaderData<{ id?: string; chat: ChatWithMessages }>();
|
||||
|
||||
const { showChat, chatStarted } = useStore(aiState);
|
||||
const actionAlert = useStore(webBuilderStore.chatStore.alert);
|
||||
useShortcuts();
|
||||
const [animationScope] = useAnimate();
|
||||
const [scrollRef] = useSnapScroll();
|
||||
const { progressAnnotations, abort, sendChatMessage } = useChatMessage({
|
||||
initialId: id,
|
||||
initialMessages: chat?.messages as unknown as ChatMessage[],
|
||||
});
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setChatId(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
const { messages } = chat;
|
||||
if (messages.length > 0) {
|
||||
setChatStarted(true);
|
||||
}
|
||||
webBuilderStore.chatStore.setReloadedMessages(messages.map((m) => m.id));
|
||||
}, [chat]);
|
||||
|
||||
const handleSendMessage = (messageInput?: string) => {
|
||||
if (!messageInput) {
|
||||
return;
|
||||
}
|
||||
sendChatMessage({ messageContent: messageInput, files: uploadFiles });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<div
|
||||
ref={animationScope}
|
||||
data-chat-visible={showChat}
|
||||
className={classNames(styles.BaseChat, 'relative flex size-full overflow-hidden')}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row size-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:w-[var(--chat-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[18vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<h1 className="text-3xl lg:text-6xl font-bold text-upage-elements-textPrimary mb-4 animate-fade-in">
|
||||
使用 UPage 构建网站
|
||||
</h1>
|
||||
<p className="text-md lg:text-xl mb-8 text-upage-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
将想法快速转变成现实,并通过可视化实时呈现。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('pt-6 px-1 sm:px-2', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
>
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={scrollRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat mb-6 mx-auto z-1 overflow-y-auto"
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
<div
|
||||
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
'sticky bottom-2': chatStarted,
|
||||
})}
|
||||
>
|
||||
<div className="bg-upage-elements-background-depth-2">
|
||||
{actionAlert && (
|
||||
<ChatAlert
|
||||
postMessage={(message) => {
|
||||
handleSendMessage?.(message);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-upage-elements-background-depth-2 p-1 rounded-lg border border-upage-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
)}
|
||||
>
|
||||
<FilePreview
|
||||
files={uploadFiles}
|
||||
onRemove={(index: number) => {
|
||||
setUploadFiles?.(uploadFiles.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => <ScreenshotStateManager uploadFiles={uploadFiles} setUploadFiles={setUploadFiles} />}
|
||||
</ClientOnly>
|
||||
<ChatTextarea
|
||||
onStopMessage={abort}
|
||||
onSendMessage={handleSendMessage}
|
||||
uploadFiles={uploadFiles}
|
||||
setUploadFiles={setUploadFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-5">
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((_event, messageInput) => {
|
||||
handleSendMessage?.(messageInput);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>{() => <WebBuilder />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
app/components/chat/ChatAlert.tsx
Normal file
112
app/components/chat/ChatAlert.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
|
||||
interface Props {
|
||||
postMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatAlert({ postMessage }: Props) {
|
||||
const actionAlert = useStore(webBuilderStore.chatStore.alert);
|
||||
|
||||
const { description, content } = useMemo(() => actionAlert ?? { description: '', content: '' }, [actionAlert]);
|
||||
|
||||
const handlePostMessage = useCallback(
|
||||
(message: string) => {
|
||||
postMessage(message);
|
||||
handleClearAlert();
|
||||
},
|
||||
[postMessage],
|
||||
);
|
||||
|
||||
const handleClearAlert = useCallback(() => {
|
||||
webBuilderStore.chatStore.clearAlert();
|
||||
}, [webBuilderStore]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`rounded-lg border border-upage-elements-borderColor bg-upage-elements-background-depth-2 p-4 mb-2`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className="flex-shrink-0"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className={`i-ph:warning-duotone text-xl text-upage-elements-button-danger-text`}></div>
|
||||
</motion.div>
|
||||
{/* Content */}
|
||||
<div className="ml-3 flex-1">
|
||||
<motion.h3
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className={`text-sm font-medium text-upage-elements-textPrimary`}
|
||||
>
|
||||
预览错误
|
||||
</motion.h3>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className={`mt-2 text-sm text-upage-elements-textSecondary`}
|
||||
>
|
||||
<p>我们遇到了预览错误。是否想让 UPage 分析并帮助解决这个问题?</p>
|
||||
{description && (
|
||||
<div className="text-xs text-upage-elements-textSecondary p-2 bg-upage-elements-background-depth-3 rounded mt-4 mb-4">
|
||||
Error: {description}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Actions */}
|
||||
<motion.div
|
||||
className="mt-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className={classNames(' flex gap-2')}>
|
||||
<button
|
||||
onClick={() => handlePostMessage(`*Fix this preview error* \n\`\`\`js\n${content}\n\`\`\`\n`)}
|
||||
className={classNames(
|
||||
`px-2 py-1.5 rounded-md text-sm font-medium`,
|
||||
'bg-upage-elements-button-primary-background',
|
||||
'hover:bg-upage-elements-button-primary-backgroundHover',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-upage-elements-button-danger-background',
|
||||
'text-upage-elements-button-primary-text',
|
||||
'flex items-center gap-1.5',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:chat-circle-duotone"></div>
|
||||
询问 UPage
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAlert}
|
||||
className={classNames(
|
||||
`px-2 py-1.5 rounded-md text-sm font-medium`,
|
||||
'bg-upage-elements-button-secondary-background',
|
||||
'hover:bg-upage-elements-button-secondary-backgroundHover',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-upage-elements-button-secondary-background',
|
||||
'text-upage-elements-button-secondary-text',
|
||||
)}
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
260
app/components/chat/ChatTextarea.tsx
Normal file
260
app/components/chat/ChatTextarea.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { useAuth, usePromptEnhancer } from '~/lib/hooks';
|
||||
import { aiState } from '~/lib/stores/ai-state';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { SendButton } from './SendButton.client';
|
||||
|
||||
interface ChatTextareaProps {
|
||||
uploadFiles: File[];
|
||||
setUploadFiles: (files: File[]) => void;
|
||||
onSendMessage: (message: string) => void;
|
||||
onStopMessage: () => void;
|
||||
}
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
export const ChatTextarea = ({ uploadFiles, setUploadFiles, onSendMessage, onStopMessage }: ChatTextareaProps) => {
|
||||
const { isAuthenticated, signIn } = useAuth();
|
||||
const { chatStarted, isStreaming } = useStore(aiState);
|
||||
const { enhancedInput, isLoading, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 检测当前 URL 是否包含登录回调参数
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedMessage = localStorage.getItem('pendingChatMessage');
|
||||
|
||||
// 如果是从登录页面回调回来的,检查 localStorage 中是否有待发送的消息
|
||||
if (savedMessage && isAuthenticated) {
|
||||
try {
|
||||
const msgData = JSON.parse(savedMessage);
|
||||
requestAnimationFrame(() => {
|
||||
if (msgData.messageInput) {
|
||||
setInput(msgData.messageInput);
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved message:', e);
|
||||
} finally {
|
||||
localStorage.removeItem('pendingChatMessage');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
setInput(enhancedInput);
|
||||
scrollTextArea();
|
||||
}, [enhancedInput]);
|
||||
|
||||
const TEXTAREA_MAX_HEIGHT = useMemo(() => {
|
||||
return chatStarted ? 400 : 200;
|
||||
}, [chatStarted]);
|
||||
|
||||
const scrollTextArea = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
}, [textareaRef]);
|
||||
|
||||
const handleEnhancePrompt = useCallback(async () => {
|
||||
try {
|
||||
await enhancePrompt(input);
|
||||
} catch (error) {
|
||||
console.error('Error enhancing prompt:', error);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input?.trim()) {
|
||||
return;
|
||||
}
|
||||
onSendMessage(input);
|
||||
setInput('');
|
||||
setUploadFiles([]);
|
||||
resetEnhancer();
|
||||
textareaRef.current?.blur();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
|
||||
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
||||
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
|
||||
}
|
||||
}, [input, textareaRef]);
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (!isAuthenticated) {
|
||||
if (input) {
|
||||
const savedMsg = {
|
||||
messageInput: input,
|
||||
timestamp: new Date().getTime(),
|
||||
};
|
||||
localStorage.setItem('pendingChatMessage', JSON.stringify(savedMsg));
|
||||
signIn();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sendMessage) {
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleFileReader(files);
|
||||
};
|
||||
|
||||
const handleFileUpload = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
handleFileReader(files ? Array.from(files) : []);
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFiles]);
|
||||
|
||||
const handleFileReader = (files: File[]) => {
|
||||
files.forEach((file) => {
|
||||
setUploadFiles?.([...uploadFiles, file]);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('relative shadow-xs backdrop-blur rounded-lg')}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={classNames(
|
||||
'w-full pl-3 pt-3 pr-16 outline-none resize-none text-upage-elements-textPrimary placeholder-upage-elements-textTertiary bg-transparent text-sm',
|
||||
'transition-[opacity,border,width,padding] duration-200',
|
||||
'hover:border-upage-elements-focus',
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--upage-elements-borderColor)';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--upage-elements-borderColor)';
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleFileReader(files);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// ignore if using input method engine
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.();
|
||||
}
|
||||
}}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
}}
|
||||
placeholder={isStreaming ? '正在构建中...' : !chatStarted ? '今天我能帮你做什么?' : '需要我优化哪些地方?'}
|
||||
translate="no"
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<SendButton
|
||||
show={input.trim().length > 0 || isStreaming}
|
||||
isStreaming={isStreaming}
|
||||
onClick={() => {
|
||||
if (isStreaming) {
|
||||
onStopMessage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.trim().length > 0) {
|
||||
handleSendMessage?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<div className="flex justify-between items-center text-sm p-3 pt-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<IconButton title="上传文件" className="transition-all" onClick={() => handleFileUpload()}>
|
||||
<div className="i-mingcute-attachment-2-line text-xl"></div>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
title="优化提示词"
|
||||
disabled={input.length === 0 || isLoading}
|
||||
className={classNames('transition-all', isLoading ? 'opacity-100' : '')}
|
||||
onClick={handleEnhancePrompt}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-upage-elements-loader-progress text-xl animate-spin"></div>
|
||||
) : (
|
||||
<div className="i-mingcute:quill-pen-ai-line text-xl"></div>
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs text-upage-elements-textTertiary">
|
||||
使用 <kbd className="kdb px-1.5 py-0.5 rounded bg-upage-elements-background-depth-2">Shift</kbd> +{' '}
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-upage-elements-background-depth-2">Return</kbd>
|
||||
换行
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
10
app/components/chat/CodeBlock.module.scss
Normal file
10
app/components/chat/CodeBlock.module.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.CopyButtonContainer {
|
||||
button:before {
|
||||
content: 'Copied';
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
left: -53px;
|
||||
padding: 2px 6px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
82
app/components/chat/CodeBlock.tsx
Normal file
82
app/components/chat/CodeBlock.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import classNames from 'classnames';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { type BundledLanguage, bundledLanguages, codeToHtml, isSpecialLang, type SpecialLanguage } from 'shiki';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
import styles from './CodeBlock.module.scss';
|
||||
|
||||
const logger = createScopedLogger('CodeBlock');
|
||||
|
||||
interface CodeBlockProps {
|
||||
className?: string;
|
||||
code: string;
|
||||
language?: BundledLanguage | SpecialLanguage;
|
||||
theme?: 'light-plus' | 'dark-plus';
|
||||
disableCopy?: boolean;
|
||||
}
|
||||
|
||||
export const CodeBlock = memo(
|
||||
({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
|
||||
const [html, setHTML] = useState<string | undefined>(undefined);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(code);
|
||||
|
||||
setCopied(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
|
||||
logger.warn(`Unsupported language '${language}'`);
|
||||
}
|
||||
|
||||
logger.trace(`Language = ${language}`);
|
||||
|
||||
const processCode = async () => {
|
||||
setHTML(await codeToHtml(code, { lang: language, theme }));
|
||||
};
|
||||
|
||||
processCode();
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className={classNames('relative group text-left', className)}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.CopyButtonContainer,
|
||||
'bg-transparent absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
|
||||
{
|
||||
'rounded-l-0 opacity-100': copied,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{!disableCopy && (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center bg-accent-500 p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300 rounded-md transition-theme transition-text-color transition-background transition-border',
|
||||
{
|
||||
'before:opacity-0': !copied,
|
||||
'before:opacity-100': copied,
|
||||
},
|
||||
)}
|
||||
title="Copy Code"
|
||||
onClick={() => copyToClipboard()}
|
||||
>
|
||||
<div className="i-ph:clipboard-text-duotone"></div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
54
app/components/chat/ElementEditPreview.tsx
Normal file
54
app/components/chat/ElementEditPreview.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import React, { useState } from 'react';
|
||||
import type { ElementInfoMetadata } from '~/types/message';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { ElementPreview } from './ElementPreview';
|
||||
|
||||
interface ElementEditPreviewProps {
|
||||
elementEditInfo: ElementInfoMetadata;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ElementEditPreview: React.FC<ElementEditPreviewProps> = ({ elementEditInfo, className = '' }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`element-edit-preview p-4 border border-upage-elements-borderColor rounded-lg bg-upage-elements-background-depth-1 shadow-sm ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between cursor-pointer" onClick={toggleExpand}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:code-block text-upage-elements-textSecondary"></div>
|
||||
<h3 className="text-sm font-medium text-upage-elements-textSecondary">
|
||||
编辑元素: {elementEditInfo.tagName.toLowerCase()}
|
||||
{elementEditInfo.className && `.${elementEditInfo.className.split(' ')[0]}`}
|
||||
{elementEditInfo.id && `#${elementEditInfo.id}`}
|
||||
</h3>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.3, ease: cubicEasingFn }}
|
||||
className="i-ph:caret-down text-upage-elements-textSecondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0, marginTop: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1, marginTop: 12 }}
|
||||
exit={{ height: 0, opacity: 0, marginTop: 0 }}
|
||||
transition={{ duration: 0.3, ease: cubicEasingFn }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<ElementPreview element={elementEditInfo} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
app/components/chat/ElementPreview.tsx
Normal file
58
app/components/chat/ElementPreview.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface ElementPreviewProps {
|
||||
element: {
|
||||
tagName: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
innerHTML?: string;
|
||||
outerHTML?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ElementPreview: React.FC<ElementPreviewProps> = ({ element }) => {
|
||||
// 提取元素标识符
|
||||
const elementIdentifier = useMemo(() => {
|
||||
const parts = [];
|
||||
parts.push(element.tagName.toLowerCase());
|
||||
|
||||
if (element.className) {
|
||||
const classes = element.className.split(' ').filter(Boolean);
|
||||
if (classes.length > 0) {
|
||||
parts.push(`.${classes[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.id) {
|
||||
parts.push(`#${element.id}`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}, [element]);
|
||||
|
||||
// 安全地渲染元素预览
|
||||
// 注意:使用 dangerouslySetInnerHTML 需要确保内容是安全的
|
||||
return (
|
||||
<div className="element-preview p-3 border border-upage-elements-borderColor rounded bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="i-ph:code text-upage-elements-textSecondary"></div>
|
||||
<div className="text-xs font-mono text-upage-elements-textSecondary">{elementIdentifier}</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'preview-container p-2 border border-dashed border-upage-elements-borderColor rounded',
|
||||
'max-h-[200px] overflow-auto',
|
||||
)}
|
||||
>
|
||||
{element.outerHTML ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: element.outerHTML }} />
|
||||
) : element.innerHTML ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: element.innerHTML }} />
|
||||
) : (
|
||||
<div className="text-xs text-upage-elements-textTertiary italic">无法显示元素预览</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
app/components/chat/ExamplePrompts.tsx
Normal file
36
app/components/chat/ExamplePrompts.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: '帮我生成一个公司的官网,展示公司的产品,突出公司的优势' },
|
||||
{ text: '创建个人使用的落地页,展示我的作品以及联系方式' },
|
||||
{ text: '制作一个漂亮的头像卡片' },
|
||||
{ text: '制作一个登录表单' },
|
||||
{ text: '使用 Tailwind CSS 制作一个响应式的导航栏' },
|
||||
];
|
||||
|
||||
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
|
||||
return (
|
||||
<div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-6">
|
||||
<div
|
||||
className="flex flex-wrap justify-center gap-2"
|
||||
style={{
|
||||
animation: '.25s ease-out 0s 1 _fade-and-move-in_g2ptj_1 forwards',
|
||||
}}
|
||||
>
|
||||
{EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event) => {
|
||||
sendMessage?.(event, examplePrompt.text);
|
||||
}}
|
||||
className="border border-upage-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-upage-elements-textSecondary hover:text-upage-elements-textPrimary px-3 py-1 text-xs transition-theme transition-text-color transition-background transition-border"
|
||||
>
|
||||
{examplePrompt.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
app/components/chat/FilePreview.tsx
Normal file
37
app/components/chat/FilePreview.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface FilePreviewProps {
|
||||
files: File[];
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
const FilePreview: React.FC<FilePreviewProps> = memo(
|
||||
({ files, onRemove }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row overflow-x-auto -mt-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name + file.size} className="mr-2 relative">
|
||||
<div className="relative pt-4 pr-4">
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} className="max-h-20" />
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="absolute top-1 right-1 z-10 bg-black rounded-full size-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div className="i-ph:x size-3 text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.files === nextProps.files;
|
||||
},
|
||||
);
|
||||
|
||||
export default FilePreview;
|
||||
171
app/components/chat/Markdown.module.scss
Normal file
171
app/components/chat/Markdown.module.scss
Normal file
@@ -0,0 +1,171 @@
|
||||
$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
$code-font-size: 13px;
|
||||
|
||||
@mixin not-inside-actions {
|
||||
&:not(:has(:global(.actions)), :global(.actions *)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
.MarkdownContent {
|
||||
line-height: 1.6;
|
||||
color: var(--upage-elements-textPrimary);
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
:global(.artifact) {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
@include not-inside-actions {
|
||||
margin-block-start: 24px;
|
||||
margin-block-end: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--upage-elements-textPrimary);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--upage-elements-borderColor);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--upage-elements-borderColor);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.85em;
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--upage-elements-messages-linkColor);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
font-family: $font-mono;
|
||||
font-size: $code-font-size;
|
||||
|
||||
@include not-inside-actions {
|
||||
border-radius: 6px;
|
||||
padding: 0.2em 0.4em;
|
||||
background-color: var(--upage-elements-messages-inlineCode-background);
|
||||
color: var(--upage-elements-messages-inlineCode-text);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 20px 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
pre:has(> code) {
|
||||
font-family: $font-mono;
|
||||
font-size: $code-font-size;
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
color: var(--upage-elements-textTertiary);
|
||||
border-left: 0.25em solid var(--upage-elements-borderColor);
|
||||
}
|
||||
|
||||
:is(ul, ol) {
|
||||
@include not-inside-actions {
|
||||
padding-left: 2em;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
@include not-inside-actions {
|
||||
list-style-type: disc;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
@include not-inside-actions {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
@include not-inside-actions {
|
||||
& + li {
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: var(--upage-elements-borderColor);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-block-end: 16px;
|
||||
|
||||
:is(th, td) {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/components/chat/Markdown.spec.ts
Normal file
48
app/components/chat/Markdown.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stripCodeFenceFromArtifact } from './Markdown';
|
||||
|
||||
describe('stripCodeFenceFromArtifact', () => {
|
||||
it('should remove code fences around artifact element', () => {
|
||||
const input = "```xml\n<div class='__uPageArtifact__'></div>\n```";
|
||||
const expected = "\n<div class='__uPageArtifact__'></div>\n";
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle code fence with language specification', () => {
|
||||
const input = "```typescript\n<div class='__uPageArtifact__'></div>\n```";
|
||||
const expected = "\n<div class='__uPageArtifact__'></div>\n";
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not modify content without artifacts', () => {
|
||||
const input = '```\nregular code block\n```';
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(stripCodeFenceFromArtifact('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle artifact without code fences', () => {
|
||||
const input = "<div class='__uPageArtifact__'></div>";
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts but only remove fences around them', () => {
|
||||
const input = [
|
||||
'Some text',
|
||||
'```typescript',
|
||||
"<div class='__uPageArtifact__'></div>",
|
||||
'```',
|
||||
'```',
|
||||
'regular code',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const expected = ['Some text', '', "<div class='__uPageArtifact__'></div>", '', '```', 'regular code', '```'].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
124
app/components/chat/Markdown.tsx
Normal file
124
app/components/chat/Markdown.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import type { BundledLanguage } from 'shiki';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { allowedHTMLElements, rehypePlugins, remarkPlugins } from '~/utils/markdown';
|
||||
import { Artifact } from './Artifact';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
|
||||
import styles from './Markdown.module.scss';
|
||||
import ThoughtBox from './ThoughtBox';
|
||||
|
||||
const logger = createScopedLogger('MarkdownComponent');
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
html?: boolean;
|
||||
limitedMarkdown?: boolean;
|
||||
}
|
||||
|
||||
export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
|
||||
logger.trace('Render');
|
||||
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
div: ({ className, children, node, ...props }) => {
|
||||
if (className?.includes('__uPageArtifact__')) {
|
||||
const messageId = node?.properties.dataMessageId as string;
|
||||
|
||||
if (!messageId) {
|
||||
logger.error(`Invalid message id ${messageId}`);
|
||||
}
|
||||
|
||||
return <Artifact messageId={messageId} />;
|
||||
}
|
||||
|
||||
if (className?.includes('__uPageThought__')) {
|
||||
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
pre: (props) => {
|
||||
const { children, node, ...rest } = props;
|
||||
|
||||
const [firstChild] = node?.children ?? [];
|
||||
|
||||
if (
|
||||
firstChild &&
|
||||
firstChild.type === 'element' &&
|
||||
firstChild.tagName === 'code' &&
|
||||
firstChild.children[0].type === 'text'
|
||||
) {
|
||||
const { className, ...rest } = firstChild.properties;
|
||||
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
|
||||
|
||||
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
|
||||
}
|
||||
|
||||
return <pre {...rest}>{children}</pre>;
|
||||
},
|
||||
} satisfies Components;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.MarkdownContent}>
|
||||
<ReactMarkdown
|
||||
allowedElements={allowedHTMLElements}
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins(limitedMarkdown)}
|
||||
rehypePlugins={rehypePlugins(html)}
|
||||
>
|
||||
{stripCodeFenceFromArtifact(children)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Removes code fence markers (```) surrounding an artifact element while preserving the artifact content.
|
||||
* This is necessary because artifacts should not be wrapped in code blocks when rendered for rendering action list.
|
||||
*
|
||||
* @param content - The markdown content to process
|
||||
* @returns The processed content with code fence markers removed around artifacts
|
||||
*
|
||||
* @example
|
||||
* // Removes code fences around artifact
|
||||
* const input = "```xml\n<div class='__uPageArtifact__'></div>\n```";
|
||||
* stripCodeFenceFromArtifact(input);
|
||||
* // Returns: "\n<div class='__uPageArtifact__'></div>\n"
|
||||
*
|
||||
* @remarks
|
||||
* - Only removes code fences that directly wrap an artifact (marked with __uPageArtifact__ class)
|
||||
* - Handles code fences with optional language specifications (e.g. ```xml, ```typescript)
|
||||
* - Preserves original content if no artifact is found
|
||||
* - Safely handles edge cases like empty input or artifacts at start/end of content
|
||||
*/
|
||||
export const stripCodeFenceFromArtifact = (content: string) => {
|
||||
if (!content || !content.includes('__uPageArtifact__')) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const artifactLineIndex = lines.findIndex((line) => line.includes('__uPageArtifact__'));
|
||||
|
||||
// Return original content if artifact line not found
|
||||
if (artifactLineIndex === -1) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Check previous line for code fence
|
||||
if (artifactLineIndex > 0 && lines[artifactLineIndex - 1]?.trim().match(/^```\w*$/)) {
|
||||
lines[artifactLineIndex - 1] = '';
|
||||
}
|
||||
|
||||
if (artifactLineIndex < lines.length - 1 && lines[artifactLineIndex + 1]?.trim().match(/^```$/)) {
|
||||
lines[artifactLineIndex + 1] = '';
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
192
app/components/chat/Messages.client.tsx
Normal file
192
app/components/chat/Messages.client.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useLocation } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { Fragment, forwardRef, memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useAuth } from '~/lib/hooks/useAuth';
|
||||
import { useChatOperate } from '~/lib/hooks/useChatOperate';
|
||||
import { useSnapScroll } from '~/lib/hooks/useSnapScroll';
|
||||
import { aiState, type ParsedUIMessage } from '~/lib/stores/ai-state';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import styles from './Messages.module.scss';
|
||||
import { UserMessage } from './UserMessage';
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const MessageItem = memo(
|
||||
forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
message: ParsedUIMessage;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isStreaming: boolean;
|
||||
userInfo: any;
|
||||
onRewind: (messageId: string) => void;
|
||||
onFork: (messageId: string) => void;
|
||||
}
|
||||
>(({ message, index, isFirst, isLast, isStreaming, userInfo, onRewind, onFork }, ref) => {
|
||||
const { role, id: messageId } = message;
|
||||
|
||||
const isUserMessage = role === 'user';
|
||||
const isHidden = message.metadata?.isHidden;
|
||||
|
||||
if (isHidden) {
|
||||
return <Fragment key={index} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(styles.messageItem, 'flex gap-4 p-5 w-full', {
|
||||
[styles.userMessage]: isUserMessage,
|
||||
[styles.assistantMessage]: !isUserMessage && (!isStreaming || !isLast),
|
||||
[styles.streamingLastMessage]: !isUserMessage && isStreaming && isLast,
|
||||
'mt-6': !isFirst,
|
||||
})}
|
||||
>
|
||||
{isUserMessage && (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start',
|
||||
)}
|
||||
>
|
||||
{userInfo?.picture ? (
|
||||
<img
|
||||
src={userInfo.picture}
|
||||
alt={userInfo?.user || userInfo.username || 'User'}
|
||||
className="size-full object-cover"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="i-ph:user-fill text-2xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames(styles.messageContent, 'grid grid-col-1 w-full')}>
|
||||
{isUserMessage ? <UserMessage message={message} /> : <AssistantMessage message={message} />}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
{messageId && (
|
||||
<WithTooltip tooltip="恢复到此消息">
|
||||
<button
|
||||
onClick={() => onRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className={classNames(
|
||||
styles.actionButton,
|
||||
'i-ph:arrow-u-up-left',
|
||||
'text-xl text-upage-elements-textSecondary hover:text-upage-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
|
||||
<WithTooltip tooltip="从此消息分叉聊天">
|
||||
<button
|
||||
onClick={() => onFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className={classNames(
|
||||
styles.actionButton,
|
||||
'i-ph:git-fork',
|
||||
'text-xl text-upage-elements-textSecondary hover:text-upage-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
|
||||
const { id } = props;
|
||||
const location = useLocation();
|
||||
const { userInfo } = useAuth();
|
||||
const { forkMessage } = useChatOperate();
|
||||
const { chatId, parseMessages, isStreaming } = useStore(aiState);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 使用useSnapScroll钩子获取自动滚动功能
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
// 组合refs: 外部传入的ref、内部的containerRef和scrollRef
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
scrollRef(containerRef.current);
|
||||
}
|
||||
|
||||
// 连接外部ref和内部ref
|
||||
if (typeof ref === 'function') {
|
||||
ref(containerRef.current);
|
||||
} else if (ref) {
|
||||
ref.current = containerRef.current;
|
||||
}
|
||||
}, [ref, scrollRef]);
|
||||
|
||||
const handleRewind = (messageId: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.set('rewindTo', messageId);
|
||||
window.location.search = searchParams.toString();
|
||||
};
|
||||
|
||||
const handleFork = async (messageId: string) => {
|
||||
try {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = await forkMessage(chatId, messageId);
|
||||
window.location.href = `/chat/${id}`;
|
||||
} catch (error) {
|
||||
toast.error('分叉聊天失败: ' + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const messageItems = useMemo(() => {
|
||||
return parseMessages.map((message, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === parseMessages.length - 1;
|
||||
|
||||
const refToApply = isLast ? messageRef : undefined;
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
ref={refToApply}
|
||||
key={`${message.id || index}`}
|
||||
message={message}
|
||||
index={index}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
isStreaming={isStreaming}
|
||||
userInfo={userInfo}
|
||||
onRewind={handleRewind}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [isStreaming, parseMessages, userInfo, messageRef]);
|
||||
|
||||
return (
|
||||
<div id={id} className={classNames(props.className, 'px-2')} ref={containerRef}>
|
||||
{parseMessages.length > 0 ? messageItems : null}
|
||||
{isStreaming && (
|
||||
<div
|
||||
className="text-center w-full text-upage-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"
|
||||
ref={messageRef}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
57
app/components/chat/Messages.module.scss
Normal file
57
app/components/chat/Messages.module.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
.messageItem {
|
||||
margin-bottom: 1rem;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
background-color: var(--upage-elements-background-depth-1);
|
||||
border-radius: 1.25rem 1.25rem 0.25rem 1.25rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.assistantMessage {
|
||||
background-color: var(--upage-elements-background-depth-2);
|
||||
border-radius: 1.25rem 1.25rem 1.25rem 0.25rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--upage-elements-icon-success);
|
||||
border: 2px solid var(--upage-elements-background);
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.streamingLastMessage {
|
||||
background-image: linear-gradient(to bottom, var(--upage-elements-messages-background) 30%, transparent 100%);
|
||||
border-radius: 1.25rem 1.25rem 1.25rem 0.25rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
315
app/components/chat/ModelSelector.tsx
Normal file
315
app/components/chat/ModelSelector.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import classNames from 'classnames';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
provider?: ProviderInfo;
|
||||
setProvider?: (provider: ProviderInfo) => void;
|
||||
modelList: ModelInfo[];
|
||||
providerList: ProviderInfo[];
|
||||
apiKeys: Record<string, string>;
|
||||
modelLoading?: string;
|
||||
}
|
||||
|
||||
export const ModelSelector = ({
|
||||
model,
|
||||
setModel,
|
||||
provider,
|
||||
setProvider,
|
||||
modelList,
|
||||
providerList,
|
||||
modelLoading,
|
||||
}: ModelSelectorProps) => {
|
||||
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<HTMLDivElement[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter models based on search query
|
||||
const filteredModels = [...modelList]
|
||||
.filter((e) => e.provider === provider?.name && e.name)
|
||||
.filter(
|
||||
(model) =>
|
||||
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
|
||||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Reset focused index when search query changes or dropdown opens/closes
|
||||
useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, [modelSearchQuery, isModelDropdownOpen]);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isModelDropdownOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isModelDropdownOpen]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isModelDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev + 1;
|
||||
|
||||
if (next >= filteredModels.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev - 1;
|
||||
|
||||
if (next < 0) {
|
||||
return filteredModels.length - 1;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
|
||||
const selectedModel = filteredModels[focusedIndex];
|
||||
setModel?.(selectedModel.name);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Focus the selected option
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
|
||||
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
// Update enabled providers when cookies change
|
||||
useEffect(() => {
|
||||
// If current provider is disabled, switch to first enabled provider
|
||||
if (providerList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
|
||||
const firstEnabledProvider = providerList[0];
|
||||
setProvider?.(firstEnabledProvider);
|
||||
|
||||
// Also update the model to the first available one for the new provider
|
||||
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
||||
|
||||
if (firstModel) {
|
||||
setModel?.(firstModel.name);
|
||||
}
|
||||
}
|
||||
}, [providerList, provider, setProvider, modelList, setModel]);
|
||||
|
||||
if (providerList.length === 0) {
|
||||
return (
|
||||
<div className="mb-2 p-4 rounded-lg border border-upage-elements-borderColor bg-upage-elements-prompt-background text-upage-elements-textPrimary">
|
||||
<p className="text-center">
|
||||
No providers are currently enabled. Please enable at least one provider in the settings to start using the
|
||||
chat.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
value={provider?.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
||||
|
||||
if (newProvider && setProvider) {
|
||||
setProvider(newProvider);
|
||||
}
|
||||
|
||||
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}}
|
||||
className="flex-1 p-2 rounded-lg border border-upage-elements-borderColor bg-upage-elements-prompt-background text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-focus transition-all"
|
||||
>
|
||||
{providerList.map((provider: ProviderInfo) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full p-2 rounded-lg border border-upage-elements-borderColor',
|
||||
'bg-upage-elements-prompt-background text-upage-elements-textPrimary',
|
||||
'focus-within:outline-none focus-within:ring-2 focus-within:ring-upage-elements-focus',
|
||||
'transition-all cursor-pointer',
|
||||
isModelDropdownOpen ? 'ring-2 ring-upage-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsModelDropdownOpen(!isModelDropdownOpen);
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-expanded={isModelDropdownOpen}
|
||||
aria-controls="model-listbox"
|
||||
aria-haspopup="listbox"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 text-upage-elements-textSecondary opacity-75',
|
||||
isModelDropdownOpen ? 'rotate-180' : undefined,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModelDropdownOpen && (
|
||||
<div
|
||||
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-upage-elements-borderColor bg-upage-elements-background-depth-2 shadow-lg"
|
||||
role="listbox"
|
||||
id="model-listbox"
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={modelSearchQuery}
|
||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
className={classNames(
|
||||
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
|
||||
'bg-upage-elements-background-depth-2 border border-upage-elements-borderColor',
|
||||
'text-upage-elements-textPrimary placeholder:text-upage-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-upage-elements-focus',
|
||||
'transition-all',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="searchbox"
|
||||
aria-label="Search models"
|
||||
/>
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
||||
<span className="i-ph:magnifying-glass text-upage-elements-textTertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'max-h-60 overflow-y-auto',
|
||||
'sm:scrollbar-none',
|
||||
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-upage-elements-borderColor',
|
||||
'[&::-webkit-scrollbar-thumb]:hover:bg-upage-elements-borderColorHover',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-track]:bg-upage-elements-background-depth-2',
|
||||
'[&::-webkit-scrollbar-track]:rounded-full',
|
||||
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
|
||||
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-upage-elements-borderColor/50',
|
||||
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-upage-elements-borderColor',
|
||||
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
)}
|
||||
>
|
||||
{modelLoading === 'all' || modelLoading === provider?.name ? (
|
||||
<div className="px-3 py-2 text-sm text-upage-elements-textTertiary">Loading...</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-upage-elements-textTertiary">No models found</div>
|
||||
) : (
|
||||
filteredModels.map((modelOption, index) => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
optionsRef.current[index] = el;
|
||||
}
|
||||
}}
|
||||
key={index}
|
||||
role="option"
|
||||
aria-selected={model === modelOption.name}
|
||||
className={classNames(
|
||||
'px-3 py-2 text-sm cursor-pointer',
|
||||
'hover:bg-upage-elements-background-depth-3',
|
||||
'text-upage-elements-textPrimary',
|
||||
'outline-none',
|
||||
model === modelOption.name || focusedIndex === index
|
||||
? 'bg-upage-elements-background-depth-2'
|
||||
: undefined,
|
||||
focusedIndex === index ? 'ring-1 ring-inset ring-upage-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModel?.(modelOption.name);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedIndex === index ? 0 : -1}
|
||||
>
|
||||
{modelOption.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
43
app/components/chat/NetlifyDeploymentLink.client.tsx
Normal file
43
app/components/chat/NetlifyDeploymentLink.client.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
export function NetlifyDeploymentLink() {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
const [deploymentUrl, setDeploymentUrl] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
setDeploymentUrl(getDeploymentByPlatform(DeploymentPlatformEnum.NETLIFY)?.url || '');
|
||||
}, [getDeploymentByPlatform]);
|
||||
|
||||
return (
|
||||
deploymentUrl && (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={deploymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center size-8 rounded hover:bg-upage-elements-item-backgroundActive text-upage-elements-textSecondary hover:text-[#00AD9F] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:link size-4 hover:text-blue-400" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-3 py-2 rounded bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-xs z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
{deploymentUrl}
|
||||
<Tooltip.Arrow className="fill-upage-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
118
app/components/chat/ProgressCompilation.tsx
Normal file
118
app/components/chat/ProgressCompilation.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import classNames from 'classnames';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ProgressAnnotation } from '~/types/message';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
|
||||
export default function ProgressCompilation({ data }: { data?: ProgressAnnotation[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const progressList = useMemo(() => {
|
||||
if (!data || data.length == 0) {
|
||||
return [];
|
||||
}
|
||||
const progressMap = new Map<string, ProgressAnnotation>();
|
||||
data.forEach((x) => {
|
||||
const existingProgress = progressMap.get(x.label);
|
||||
|
||||
if (existingProgress && existingProgress.status === 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
progressMap.set(x.label, x);
|
||||
});
|
||||
|
||||
return Array.from(progressMap.values()).sort((a, b) => a.order - b.order);
|
||||
}, [data]);
|
||||
|
||||
if (progressList.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-upage-elements-background-depth-2',
|
||||
'border border-upage-elements-borderColor',
|
||||
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
|
||||
'p-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-upage-elements-item-backgroundAccent',
|
||||
'py-1 px-1.5 rounded-md text-upage-elements-item-contentAccent',
|
||||
'flex items-center',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<AnimatePresence>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
className="actions"
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: '0px' }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{progressList.map((x, i) => {
|
||||
return <ProgressItem key={i} progress={x} />;
|
||||
})}
|
||||
</motion.div>
|
||||
) : (
|
||||
<ProgressItem progress={progressList.slice(-1)[0]} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{progressList.length > 1 && (
|
||||
<motion.button
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: 'auto' }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ duration: 0.15, ease: cubicEasingFn }}
|
||||
className="p-1 rounded-lg bg-upage-elements-item-backgroundAccent hover:bg-upage-elements-artifacts-backgroundHover"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<div className={expanded ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressItemProps {
|
||||
progress: ProgressAnnotation;
|
||||
}
|
||||
|
||||
const ProgressItem = ({ progress }: ProgressItemProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
className={classNames('flex text-sm gap-3 items-center justify-between', {
|
||||
'text-upage-elements-textSuccess': progress.status === 'complete',
|
||||
'text-upage-elements-textError': progress.status === 'stopped',
|
||||
})}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div>
|
||||
{progress.status === 'in-progress' ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg"></div>
|
||||
) : progress.status === 'complete' ? (
|
||||
<div className="i-ph:check"></div>
|
||||
) : progress.status === 'stopped' ? (
|
||||
<div className="i-ph:x"></div>
|
||||
) : progress.status === 'warning' ? (
|
||||
<div className="i-ph:warning"></div>
|
||||
) : null}
|
||||
</div>
|
||||
{progress.message}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
22
app/components/chat/ScreenshotStateManager.tsx
Normal file
22
app/components/chat/ScreenshotStateManager.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ScreenshotStateManagerProps {
|
||||
setUploadFiles?: (files: File[]) => void;
|
||||
uploadFiles: File[];
|
||||
}
|
||||
|
||||
export const ScreenshotStateManager = ({ setUploadFiles, uploadFiles }: ScreenshotStateManagerProps) => {
|
||||
useEffect(() => {
|
||||
if (setUploadFiles) {
|
||||
(window as any).__UPAGE_SET_UPLOADED_FILES__ = setUploadFiles;
|
||||
(window as any).__UPAGE_UPLOADED_FILES__ = uploadFiles;
|
||||
}
|
||||
|
||||
return () => {
|
||||
delete (window as any).__UPAGE_SET_UPLOADED_FILES__;
|
||||
delete (window as any).__UPAGE_UPLOADED_FILES__;
|
||||
};
|
||||
}, [setUploadFiles, uploadFiles]);
|
||||
|
||||
return null;
|
||||
};
|
||||
43
app/components/chat/SendButton.client.tsx
Normal file
43
app/components/chat/SendButton.client.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
|
||||
|
||||
interface SendButtonProps {
|
||||
show: boolean;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onImagesSelected?: (images: File[]) => void;
|
||||
}
|
||||
|
||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
|
||||
export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
<motion.button
|
||||
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme transition-text-color transition-background transition-border disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
transition={{ ease: customEasingFn, duration: 0.17 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!disabled) {
|
||||
onClick?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-lg">
|
||||
{!isStreaming ? (
|
||||
<div className="i-mingcute:arrow-right-line"></div>
|
||||
) : (
|
||||
<div className="i-mingcute:stop-circle-line"></div>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
27
app/components/chat/SpeechRecognition.tsx
Normal file
27
app/components/chat/SpeechRecognition.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import classNames from 'classnames';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
|
||||
export const SpeechRecognitionButton = ({
|
||||
isListening,
|
||||
onStart,
|
||||
onStop,
|
||||
disabled,
|
||||
}: {
|
||||
isListening: boolean;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<IconButton
|
||||
title={isListening ? 'Stop listening' : 'Start speech recognition'}
|
||||
disabled={disabled}
|
||||
className={classNames('transition-all', {
|
||||
'text-upage-elements-item-contentAccent': isListening,
|
||||
})}
|
||||
onClick={isListening ? onStop : onStart}
|
||||
>
|
||||
{isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
44
app/components/chat/ThoughtBox.tsx
Normal file
44
app/components/chat/ThoughtBox.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { type PropsWithChildren, useState } from 'react';
|
||||
|
||||
const ThoughtBox = ({ title, children }: PropsWithChildren<{ title: string }>) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`
|
||||
bg-upage-elements-background-depth-2
|
||||
shadow-md
|
||||
rounded-lg
|
||||
cursor-pointer
|
||||
transition-all
|
||||
duration-300
|
||||
${isExpanded ? 'max-h-96' : 'max-h-13'}
|
||||
overflow-hidden
|
||||
border border-upage-elements-borderColor
|
||||
`}
|
||||
>
|
||||
<div className="p-4 flex items-center gap-4 rounded-lg text-upage-elements-textSecondary font-medium leading-5 text-sm border border-upage-elements-borderColor">
|
||||
<div className="i-ph:brain-thin text-2xl" />
|
||||
<div className="div">
|
||||
<span> {title}</span>{' '}
|
||||
{!isExpanded && <span className="text-upage-elements-textTertiary"> - Click to expand</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
transition-opacity
|
||||
duration-300
|
||||
p-4
|
||||
rounded-lg
|
||||
overflow-auto
|
||||
${isExpanded ? 'opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThoughtBox;
|
||||
36
app/components/chat/UserMessage.tsx
Normal file
36
app/components/chat/UserMessage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FileUIPart } from 'ai';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
import { ElementEditPreview } from './ElementEditPreview';
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
export function UserMessage({ message }: { message: UPageUIMessage }) {
|
||||
const parts = message.parts;
|
||||
const textContent = stripMetadata(parts.find((part) => part.type === 'text')?.text || '');
|
||||
const images = parts.filter((part) => part.type === 'file' && part.mediaType.startsWith('image')) as FileUIPart[];
|
||||
const elementInfo = message.metadata?.elementInfo;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{textContent && <Markdown html>{textContent}</Markdown>}
|
||||
{images.map((item, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={item.url}
|
||||
alt={item.filename || `Image ${index + 1}`}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
style={{ maxHeight: '512px', objectFit: 'contain' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{elementInfo && <ElementEditPreview elementEditInfo={elementInfo} className="mt-3" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stripMetadata(content: string) {
|
||||
const artifactRegex = /<uPageArtifact\s+[^>]*>[\s\S]*?<\/uPageArtifact>/gm;
|
||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').replace(artifactRegex, '');
|
||||
}
|
||||
44
app/components/chat/VercelDeploymentLink.client.tsx
Normal file
44
app/components/chat/VercelDeploymentLink.client.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
export function VercelDeploymentLink() {
|
||||
const [deploymentUrl, setDeploymentUrl] = useState<string | undefined>(undefined);
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
|
||||
useEffect(() => {
|
||||
setDeploymentUrl(getDeploymentByPlatform(DeploymentPlatformEnum.VERCEL)?.url || '');
|
||||
}, [getDeploymentByPlatform]);
|
||||
|
||||
return (
|
||||
deploymentUrl && (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={deploymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex justify-end items-center justify-center size-8 rounded hover:bg-upage-elements-item-backgroundActive text-upage-elements-textSecondary hover:text-[#000000] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={`i-ph:link size-4 hover:text-blue-400`} />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-3 py-2 rounded bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-xs z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
{deploymentUrl}
|
||||
<Tooltip.Arrow className="fill-upage-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
44
app/components/chat/_1PanelDeploymentLink.client.tsx
Normal file
44
app/components/chat/_1PanelDeploymentLink.client.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
export function _1PanelDeploymentLink() {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
const [deploymentUrl, setDeploymentUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setDeploymentUrl(getDeploymentByPlatform(DeploymentPlatformEnum._1PANEL)?.url || '');
|
||||
}, [getDeploymentByPlatform]);
|
||||
|
||||
return (
|
||||
deploymentUrl && (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={deploymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center size-8 rounded hover:bg-upage-elements-item-backgroundActive text-upage-elements-textSecondary hover:text-[#00AD9F] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:link size-4 hover:text-blue-400" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-3 py-2 rounded bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-xs z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
{deploymentUrl}
|
||||
<Tooltip.Arrow className="fill-upage-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
12
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
12
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
|
||||
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
|
||||
return (
|
||||
<WithTooltip tooltip="导出聊天">
|
||||
<IconButton title="导出聊天" onClick={() => exportChat?.()}>
|
||||
<div className="i-ph:download-simple text-xl"></div>
|
||||
</IconButton>
|
||||
</WithTooltip>
|
||||
);
|
||||
};
|
||||
424
app/components/chat/usage/ChatUsageDialog.tsx
Normal file
424
app/components/chat/usage/ChatUsageDialog.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import classNames from 'classnames';
|
||||
import { motion, type Transition, type Variants } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { useChatUsage } from '~/lib/hooks/useChatUsage';
|
||||
import { DialogDescription, DialogTitle } from '../../ui/Dialog';
|
||||
import { IconButton } from '../../ui/IconButton';
|
||||
import { ChatUsageVisualization } from './ChatUsageVisualization';
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 0.15,
|
||||
ease: [0.16, 1, 0.3, 1], // cubicBezier(.16,1,.3,1)
|
||||
};
|
||||
|
||||
const backdropVariants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition,
|
||||
},
|
||||
open: {
|
||||
opacity: 1,
|
||||
transition,
|
||||
},
|
||||
};
|
||||
|
||||
const dialogVariants: Variants = {
|
||||
closed: {
|
||||
x: '-50%',
|
||||
y: '-40%',
|
||||
scale: 0.96,
|
||||
opacity: 0,
|
||||
transition,
|
||||
},
|
||||
open: {
|
||||
x: '-50%',
|
||||
y: '-50%',
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition,
|
||||
},
|
||||
};
|
||||
|
||||
interface ChatUsageDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ChatUsageDialog = memo(({ isOpen, onClose }: ChatUsageDialogProps) => {
|
||||
const { usageStats, isLoading, refreshUsageStats } = useChatUsage();
|
||||
|
||||
const formatNumber = (num: number | null) => {
|
||||
if (num === null) {
|
||||
return '0';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number | null) => {
|
||||
if (num === null) {
|
||||
return '0';
|
||||
}
|
||||
if (num < 1000) {
|
||||
return num.toString();
|
||||
}
|
||||
if (num < 1000000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
// 计算成功率
|
||||
const successRate = () => {
|
||||
if (!usageStats) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const successCount = usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0;
|
||||
const totalCount = usageStats.total._count;
|
||||
|
||||
return totalCount > 0 ? (successCount / totalCount) * 100 : 0;
|
||||
};
|
||||
|
||||
// 计算平均 token 消耗
|
||||
const avgTokenPerRequest = () => {
|
||||
if (!usageStats || usageStats.total._count === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (usageStats.total._sum.totalTokens || 0) / usageStats.total._count;
|
||||
};
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-4 rounded-lg shadow-sm',
|
||||
'bg-upage-elements-bg-depth-1',
|
||||
'border border-upage-elements-borderColor',
|
||||
);
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={backdropVariants}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[95vw] max-w-[1000px] max-h-[85vh] flex flex-col',
|
||||
)}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={dialogVariants}
|
||||
>
|
||||
<DialogDescription className="sr-only">
|
||||
用于展示 API 使用情况统计数据,包括请求次数、Token 用量及成功率等信息。
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
|
||||
<DialogTitle>API 使用统计</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconButton
|
||||
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
|
||||
onClick={refreshUsageStats}
|
||||
disabled={isLoading}
|
||||
className={classNames('text-upage-elements-textTertiary hover:text-upage-elements-textSecondary', {
|
||||
'opacity-50 cursor-not-allowed': isLoading,
|
||||
})}
|
||||
aria-label="刷新统计数据"
|
||||
title="刷新统计数据"
|
||||
/>
|
||||
<RadixDialog.Close asChild onClick={onClose}>
|
||||
<IconButton
|
||||
icon="i-ph:x"
|
||||
className="text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
|
||||
/>
|
||||
</RadixDialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 dark:bg-gray-950/70 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-5 text-upage-elements-textTertiary" />
|
||||
<span className="text-upage-elements-textSecondary font-medium">数据刷新中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!usageStats ? (
|
||||
<div className="flex-1 overflow-auto text-center py-12">
|
||||
<div className="i-ph:chart-line-duotone size-12 mx-auto mb-4 text-upage-elements-textTertiary opacity-80" />
|
||||
<h3 className="text-lg font-medium text-upage-elements-textPrimary mb-2">暂无数据</h3>
|
||||
<p className="text-upage-elements-textSecondary">还没有使用记录,开始使用 AI 功能来生成数据统计。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className={cardClasses}>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">总请求次数</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:chat-dots-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
{formatNumber(usageStats.total._count)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">总 Token 用量</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:hash-duotone size-6 text-green-500 dark:text-green-400 mr-2" />
|
||||
{formatLargeNumber(usageStats.total._sum.totalTokens)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">输入 Token</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:export-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
|
||||
{formatLargeNumber(usageStats.total._sum.inputTokens)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">输出 Token</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:import-duotone size-6 text-amber-500 dark:text-amber-400 mr-2" />
|
||||
{formatLargeNumber(usageStats.total._sum.outputTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
|
||||
<span className="i-ph:chart-pie-slice-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
请求成功率
|
||||
</h3>
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-3xl font-bold text-upage-elements-textPrimary">
|
||||
{successRate().toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-upage-elements-textSecondary">
|
||||
{usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0} /{' '}
|
||||
{usageStats.total._count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 dark:bg-green-400 rounded-full"
|
||||
style={{ width: `${Math.min(100, successRate())}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{usageStats.byStatus.map((status) => (
|
||||
<div key={status.status} className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={classNames('size-2 rounded-full mr-2', {
|
||||
'bg-green-500 dark:bg-green-400': status.status === 'SUCCESS',
|
||||
'bg-red-500 dark:bg-red-400': status.status === 'FAILED',
|
||||
'bg-yellow-500 dark:bg-yellow-400': status.status === 'PENDING',
|
||||
'bg-gray-500 dark:bg-gray-400': !['SUCCESS', 'FAILED', 'PENDING'].includes(
|
||||
status.status,
|
||||
),
|
||||
})}
|
||||
/>
|
||||
<div className="text-sm text-upage-elements-textSecondary capitalize">
|
||||
{status.status === 'SUCCESS'
|
||||
? '成功'
|
||||
: status.status === 'FAILED'
|
||||
? '失败'
|
||||
: status.status === 'PENDING'
|
||||
? '处理中'
|
||||
: status.status === 'ABORTED'
|
||||
? '中止'
|
||||
: status.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{formatNumber(status._count)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
|
||||
<span className="i-ph:check-circle-duotone size-5 text-green-500 dark:text-green-400 mr-2" />
|
||||
Token 消耗统计
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">平均每次请求消耗</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary">
|
||||
{formatLargeNumber(avgTokenPerRequest())} Tokens
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-upage-elements-borderColor">
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-2">Token 类型分布</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-sm text-upage-elements-textSecondary flex items-center">
|
||||
<div className="size-2 rounded-full bg-blue-500 dark:bg-blue-400 mr-2" />
|
||||
输入 Token
|
||||
</div>
|
||||
<div className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{formatLargeNumber(usageStats.total._sum.inputTokens)}
|
||||
<span className="text-xs text-upage-elements-textTertiary ml-1">
|
||||
(
|
||||
{usageStats.total._sum.totalTokens
|
||||
? (
|
||||
((usageStats.total._sum.inputTokens || 0) /
|
||||
usageStats.total._sum.totalTokens) *
|
||||
100
|
||||
).toFixed(0)
|
||||
: 0}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div className="text-sm text-upage-elements-textSecondary flex items-center">
|
||||
<div className="size-2 rounded-full bg-amber-500 dark:bg-amber-400 mr-2" />
|
||||
输出 Token
|
||||
</div>
|
||||
<div className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{formatLargeNumber(usageStats.total._sum.outputTokens)}
|
||||
<span className="text-xs text-upage-elements-textTertiary ml-1">
|
||||
(
|
||||
{usageStats.total._sum.totalTokens
|
||||
? (
|
||||
((usageStats.total._sum.outputTokens || 0) /
|
||||
usageStats.total._sum.totalTokens) *
|
||||
100
|
||||
).toFixed(0)
|
||||
: 0}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div className="text-sm text-upage-elements-textSecondary flex items-center">
|
||||
<div className="size-2 rounded-full bg-green-500 dark:bg-green-400 mr-2" />
|
||||
缓存 Token
|
||||
</div>
|
||||
<div className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{formatLargeNumber(usageStats.total._sum.cachedTokens)}
|
||||
<span className="text-xs text-upage-elements-textTertiary ml-1">
|
||||
(
|
||||
{usageStats.total._sum.totalTokens
|
||||
? (
|
||||
((usageStats.total._sum.cachedTokens || 0) /
|
||||
usageStats.total._sum.totalTokens) *
|
||||
100
|
||||
).toFixed(0)
|
||||
: 0}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
|
||||
<span className="i-ph:trend-up-duotone size-5 text-blue-500 dark:text-blue-400 mr-2" />
|
||||
使用趋势
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{usageStats.byDate.length > 0 ? (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">今日请求</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary">
|
||||
{usageStats.byDate.length > 0
|
||||
? formatNumber(usageStats.byDate[usageStats.byDate.length - 1].count)
|
||||
: '0'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-upage-elements-borderColor">
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-2">最近趋势</div>
|
||||
<div className="text-sm text-upage-elements-textTertiary">
|
||||
{usageStats.byDate.length > 1 ? (
|
||||
(() => {
|
||||
const current = usageStats.byDate[usageStats.byDate.length - 1].count;
|
||||
const previous = usageStats.byDate[usageStats.byDate.length - 2].count;
|
||||
const diff = current - previous;
|
||||
const percentage = previous !== 0 ? (diff / previous) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className="text-upage-elements-textSecondary">较前一日:</span>
|
||||
<span
|
||||
className={classNames('ml-1 flex items-center', {
|
||||
'text-green-500 dark:text-green-400': diff > 0,
|
||||
'text-red-500 dark:text-red-400': diff < 0,
|
||||
'text-upage-elements-textTertiary': diff === 0,
|
||||
})}
|
||||
>
|
||||
{diff > 0 ? (
|
||||
<span className="i-ph:arrow-up size-3.5 mr-0.5"></span>
|
||||
) : diff < 0 ? (
|
||||
<span className="i-ph:arrow-down size-3.5 mr-0.5"></span>
|
||||
) : (
|
||||
<span className="i-ph:minus size-3.5 mr-0.5"></span>
|
||||
)}
|
||||
{Math.abs(percentage).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span>尚无对比数据</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full py-6">
|
||||
<span className="text-sm text-upage-elements-textSecondary">暂无数据</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
|
||||
<div className="p-4 border-b border-upage-elements-borderColor">
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:chart-line-up-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
使用统计图表
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ChatUsageVisualization usageStats={usageStats} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
});
|
||||
372
app/components/chat/usage/ChatUsageVisualization.tsx
Normal file
372
app/components/chat/usage/ChatUsageVisualization.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import { Doughnut, Line, Pie } from 'react-chartjs-2';
|
||||
import type { ChatUsageStats } from '~/lib/hooks/useChatUsage';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement);
|
||||
|
||||
type ChatUsageVisualizationProps = {
|
||||
usageStats: ChatUsageStats;
|
||||
};
|
||||
|
||||
export function ChatUsageVisualization({ usageStats }: ChatUsageVisualizationProps) {
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
|
||||
|
||||
const getThemeColor = (varName: string): string => {
|
||||
if (typeof document !== 'undefined') {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
}
|
||||
return isDarkMode ? '#FFFFFF' : '#000000';
|
||||
};
|
||||
|
||||
const chartColors = {
|
||||
grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
text: getThemeColor('--upage-elements-textPrimary'),
|
||||
textSecondary: getThemeColor('--upage-elements-textSecondary'),
|
||||
background: getThemeColor('--upage-elements-bg-depth-1'),
|
||||
accent: getThemeColor('--upage-elements-button-primary-text'),
|
||||
border: getThemeColor('--upage-elements-borderColor'),
|
||||
success: isDarkMode ? 'rgba(34, 197, 94, 0.7)' : 'rgba(34, 197, 94, 0.6)', // 绿色
|
||||
successBorder: isDarkMode ? 'rgba(34, 197, 94, 0.9)' : 'rgba(34, 197, 94, 0.8)',
|
||||
failed: isDarkMode ? 'rgba(239, 68, 68, 0.7)' : 'rgba(239, 68, 68, 0.6)', // 红色
|
||||
failedBorder: isDarkMode ? 'rgba(239, 68, 68, 0.9)' : 'rgba(239, 68, 68, 0.8)',
|
||||
pending: isDarkMode ? 'rgba(234, 179, 8, 0.7)' : 'rgba(234, 179, 8, 0.6)', // 黄色
|
||||
pendingBorder: isDarkMode ? 'rgba(234, 179, 8, 0.9)' : 'rgba(234, 179, 8, 0.8)',
|
||||
aborted: isDarkMode ? 'rgba(107, 114, 128, 0.7)' : 'rgba(107, 114, 128, 0.6)', // 灰色
|
||||
abortedBorder: isDarkMode ? 'rgba(107, 114, 128, 0.9)' : 'rgba(107, 114, 128, 0.8)',
|
||||
};
|
||||
|
||||
const getChartColors = (index: number) => {
|
||||
const baseColors = [
|
||||
{
|
||||
base: getThemeColor('--upage-elements-button-primary-text'),
|
||||
},
|
||||
{
|
||||
base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)',
|
||||
},
|
||||
{
|
||||
base: getThemeColor('--upage-elements-icon-success'),
|
||||
},
|
||||
{
|
||||
base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)',
|
||||
},
|
||||
{
|
||||
base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)',
|
||||
},
|
||||
];
|
||||
|
||||
const color = baseColors[index % baseColors.length].base;
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/);
|
||||
|
||||
if (rgbMatch) {
|
||||
[, r, g, b] = rgbMatch.map(Number);
|
||||
} else if (rgbaMatch) {
|
||||
[, r, g, b] = rgbaMatch.map(Number);
|
||||
} else if (color.startsWith('#')) {
|
||||
const hex = color.slice(1);
|
||||
const bigint = parseInt(hex, 16);
|
||||
r = (bigint >> 16) & 255;
|
||||
g = (bigint >> 8) & 255;
|
||||
b = bigint & 255;
|
||||
}
|
||||
|
||||
return {
|
||||
bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`,
|
||||
border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`,
|
||||
};
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
return '成功';
|
||||
case 'FAILED':
|
||||
return '失败';
|
||||
case 'PENDING':
|
||||
return '处理中';
|
||||
case 'ABORTED':
|
||||
return '中止';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string, isBackground = true) => {
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
return isBackground ? chartColors.success : chartColors.successBorder;
|
||||
case 'FAILED':
|
||||
return isBackground ? chartColors.failed : chartColors.failedBorder;
|
||||
case 'PENDING':
|
||||
return isBackground ? chartColors.pending : chartColors.pendingBorder;
|
||||
case 'ABORTED':
|
||||
return isBackground ? chartColors.aborted : chartColors.abortedBorder;
|
||||
default:
|
||||
return isBackground ? getChartColors(0).bg : getChartColors(0).border;
|
||||
}
|
||||
};
|
||||
|
||||
const statusDistributionData = {
|
||||
labels: usageStats.byStatus.map((status) => formatStatus(status.status)),
|
||||
datasets: [
|
||||
{
|
||||
label: '请求状态',
|
||||
data: usageStats.byStatus.map((status) => status._count),
|
||||
backgroundColor: usageStats.byStatus.map((status) => getStatusColor(status.status)),
|
||||
borderColor: usageStats.byStatus.map((status) => getStatusColor(status.status, false)),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tokenUsageData = {
|
||||
labels: ['输入 Token', '输出 Token', '缓存 Token'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Token 使用量',
|
||||
data: [
|
||||
usageStats.total._sum.inputTokens || 0,
|
||||
usageStats.total._sum.outputTokens || 0,
|
||||
usageStats.total._sum.cachedTokens || 0,
|
||||
],
|
||||
backgroundColor: [getChartColors(1).bg, getChartColors(2).bg, getChartColors(4).bg],
|
||||
borderColor: [getChartColors(1).border, getChartColors(2).border, getChartColors(4).border],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const dailyRequestsData = {
|
||||
labels: usageStats.byDate.map((day) => day.date),
|
||||
datasets: [
|
||||
{
|
||||
label: '每日请求数',
|
||||
data: usageStats.byDate.map((day) => day.count),
|
||||
borderColor: getChartColors(4).border,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.4, // 添加曲线平滑
|
||||
pointBackgroundColor: getChartColors(4).border,
|
||||
pointBorderColor: chartColors.background,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: '每日 Token 用量',
|
||||
data: usageStats.byDate.map((day) => day.totalTokens),
|
||||
borderColor: getChartColors(2).border,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: getChartColors(2).border,
|
||||
pointBorderColor: chartColors.background,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const baseChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
color: chartColors.text,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
size: 12,
|
||||
},
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
padding: 16,
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: chartColors.text,
|
||||
bodyColor: chartColors.text,
|
||||
backgroundColor: isDarkMode ? 'rgba(23, 23, 23, 0.8)' : 'rgba(255, 255, 255, 0.8)',
|
||||
borderColor: chartColors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const statusPieOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: '请求状态分布',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
position: 'right' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const doughnutOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: 'Token 使用分布',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
position: 'right' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lineChartOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: '每日请求统计',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
onClick: function (_e: any, legendItem: any, legend: any) {
|
||||
const index = legendItem.datasetIndex;
|
||||
const ci = legend.chart;
|
||||
|
||||
const datasets = ci.data.datasets;
|
||||
const visibleCount = datasets.reduce((count: number, _dataset: any, i: number) => {
|
||||
return count + (ci.getDatasetMeta(i).hidden ? 0 : 1);
|
||||
}, 0);
|
||||
|
||||
const meta = ci.getDatasetMeta(index);
|
||||
const isCurrentlyVisible = !meta.hidden;
|
||||
|
||||
if (isCurrentlyVisible && visibleCount === 1) {
|
||||
meta.hidden = true;
|
||||
|
||||
datasets.forEach((_dataset: any, i: number) => {
|
||||
if (i !== index) {
|
||||
ci.getDatasetMeta(i).hidden = false;
|
||||
}
|
||||
});
|
||||
} else if (visibleCount === 0) {
|
||||
datasets.forEach((_dataset: any, i: number) => {
|
||||
ci.getDatasetMeta(i).hidden = i !== index;
|
||||
});
|
||||
} else {
|
||||
meta.hidden = !meta.hidden;
|
||||
}
|
||||
|
||||
ci.update();
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-6 rounded-lg shadow-sm',
|
||||
'bg-upage-elements-bg-depth-1',
|
||||
'border border-upage-elements-borderColor',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">每日请求统计</h3>
|
||||
<div className="h-64">
|
||||
<Line data={dailyRequestsData} options={lineChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">请求状态分布</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={statusDistributionData} options={statusPieOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">Token 使用分布</h3>
|
||||
<div className="h-64">
|
||||
<Doughnut data={tokenUsageData} options={doughnutOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
639
app/components/chat/usage/DeploymentRecordsDialog.tsx
Normal file
639
app/components/chat/usage/DeploymentRecordsDialog.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import classNames from 'classnames';
|
||||
import { motion, type Transition, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { type DeploymentRecord, useDeploymentRecords } from '~/lib/hooks/useDeploymentRecords';
|
||||
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
|
||||
import { ConfirmationDialog, DialogDescription, DialogTitle } from '../../ui/Dialog';
|
||||
import { IconButton } from '../../ui/IconButton';
|
||||
import { Tabs, TabsList, TabsTrigger } from '../../ui/Tabs';
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 0.15,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
};
|
||||
|
||||
const backdropVariants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition,
|
||||
},
|
||||
open: {
|
||||
opacity: 1,
|
||||
transition,
|
||||
},
|
||||
};
|
||||
|
||||
const dialogVariants: Variants = {
|
||||
closed: {
|
||||
x: '-50%',
|
||||
y: '-40%',
|
||||
scale: 0.96,
|
||||
opacity: 0,
|
||||
transition,
|
||||
},
|
||||
open: {
|
||||
x: '-50%',
|
||||
y: '-50%',
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition,
|
||||
},
|
||||
};
|
||||
|
||||
interface DeploymentRecordsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DeploymentRecordsDialog = memo(({ isOpen, onClose }: DeploymentRecordsDialogProps) => {
|
||||
const {
|
||||
deploymentRecords,
|
||||
totals,
|
||||
stats,
|
||||
isLoading,
|
||||
isPlatformLoading,
|
||||
loadPlatformRecords,
|
||||
refreshDeploymentRecords,
|
||||
toggleAccess,
|
||||
deletePage,
|
||||
} = useDeploymentRecords();
|
||||
const [activePlatform, setActivePlatform] = useState<DeploymentPlatformEnum>(DeploymentPlatformEnum._1PANEL);
|
||||
const [loadedPlatforms, setLoadedPlatforms] = useState<Set<string>>(new Set());
|
||||
const initialLoadDone = useRef<boolean>(false);
|
||||
|
||||
// 确认对话框状态
|
||||
type ConfirmAction = 'toggle-access' | 'delete';
|
||||
type ConfirmDialogState = {
|
||||
isOpen: boolean;
|
||||
action: ConfirmAction;
|
||||
recordId: string | null;
|
||||
platform: string | null;
|
||||
recordStatus?: string;
|
||||
};
|
||||
|
||||
const [confirmDialogState, setConfirmDialogState] = useState<ConfirmDialogState>({
|
||||
isOpen: false,
|
||||
action: 'toggle-access',
|
||||
recordId: null,
|
||||
platform: null,
|
||||
});
|
||||
|
||||
const [isConfirmationLoading, setIsConfirmationLoading] = useState(false);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
const currentRecords = deploymentRecords[activePlatform] || [];
|
||||
loadPlatformRecords({ offset: currentRecords.length, platform: activePlatform });
|
||||
}, [activePlatform, deploymentRecords, loadPlatformRecords]);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
const newPlatform = value as DeploymentPlatformEnum;
|
||||
setActivePlatform(newPlatform);
|
||||
|
||||
if (!loadedPlatforms.has(newPlatform)) {
|
||||
loadPlatformRecords({ platform: newPlatform });
|
||||
setLoadedPlatforms((prev) => new Set(prev).add(newPlatform));
|
||||
}
|
||||
},
|
||||
[loadPlatformRecords, loadedPlatforms],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !initialLoadDone.current) {
|
||||
refreshDeploymentRecords();
|
||||
setLoadedPlatforms((prev) => new Set(prev).add(activePlatform));
|
||||
initialLoadDone.current = true;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
initialLoadDone.current = false;
|
||||
}
|
||||
}, [isOpen, activePlatform, refreshDeploymentRecords]);
|
||||
|
||||
const openConfirmDialog = useCallback((action: ConfirmAction, record: DeploymentRecord) => {
|
||||
setConfirmDialogState({
|
||||
isOpen: true,
|
||||
action,
|
||||
recordId: record.id,
|
||||
platform: record.platform,
|
||||
recordStatus: record.status,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeConfirmDialog = useCallback(() => {
|
||||
setConfirmDialogState((prev) => ({ ...prev, isOpen: false }));
|
||||
}, []);
|
||||
|
||||
const handleConfirmAction = useCallback(async () => {
|
||||
const { action, recordId, platform } = confirmDialogState;
|
||||
if (!recordId || !platform) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationLoading(true);
|
||||
|
||||
try {
|
||||
if (action === 'toggle-access') {
|
||||
await toggleAccess(recordId, platform);
|
||||
}
|
||||
if (action === 'delete') {
|
||||
await deletePage(recordId, platform);
|
||||
}
|
||||
|
||||
refreshDeploymentRecords();
|
||||
closeConfirmDialog();
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
toast.error('操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
} finally {
|
||||
setIsConfirmationLoading(false);
|
||||
}
|
||||
}, [confirmDialogState, toggleAccess, deletePage, refreshDeploymentRecords, closeConfirmDialog]);
|
||||
|
||||
const handleToggleAccess = useCallback(
|
||||
(record: DeploymentRecord) => {
|
||||
openConfirmDialog('toggle-access', record);
|
||||
},
|
||||
[openConfirmDialog],
|
||||
);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
(record: DeploymentRecord) => {
|
||||
openConfirmDialog('delete', record);
|
||||
},
|
||||
[openConfirmDialog],
|
||||
);
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-4 rounded-lg shadow-sm',
|
||||
'bg-upage-elements-bg-depth-1',
|
||||
'border border-upage-elements-borderColor',
|
||||
);
|
||||
|
||||
const platformIcons = {
|
||||
[DeploymentPlatformEnum._1PANEL]: 'i-ph:browser',
|
||||
[DeploymentPlatformEnum.NETLIFY]: 'i-ph:cloud',
|
||||
[DeploymentPlatformEnum.VERCEL]: 'i-ph:triangle',
|
||||
};
|
||||
|
||||
// 状态配置类型
|
||||
type StatusConfig = {
|
||||
text: string;
|
||||
bgClass: string;
|
||||
dotClass: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
// 部署状态配置
|
||||
const deploymentStatusConfig: Record<string, StatusConfig> = {
|
||||
[DeploymentStatusEnum.SUCCESS]: {
|
||||
text: '已部署',
|
||||
bgClass: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
dotClass: 'bg-green-500 dark:bg-green-400',
|
||||
icon: 'i-carbon:checkmark-filled',
|
||||
},
|
||||
[DeploymentStatusEnum.DEPLOYED]: {
|
||||
text: '已部署',
|
||||
bgClass: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
dotClass: 'bg-green-500 dark:bg-green-400',
|
||||
icon: 'i-carbon:checkmark-filled',
|
||||
},
|
||||
[DeploymentStatusEnum.PENDING]: {
|
||||
text: '部署中',
|
||||
bgClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
dotClass: 'bg-yellow-500 dark:bg-yellow-400',
|
||||
icon: 'i-carbon:time',
|
||||
},
|
||||
[DeploymentStatusEnum.DEPLOYING]: {
|
||||
text: '部署中',
|
||||
bgClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
dotClass: 'bg-yellow-500 dark:bg-yellow-400',
|
||||
icon: 'i-carbon:in-progress',
|
||||
},
|
||||
[DeploymentStatusEnum.FAILED]: {
|
||||
text: '失败',
|
||||
bgClass: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
dotClass: 'bg-red-500 dark:bg-red-400',
|
||||
icon: 'i-carbon:close-filled',
|
||||
},
|
||||
[DeploymentStatusEnum.INACTIVE]: {
|
||||
text: '已停用',
|
||||
bgClass: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
dotClass: 'bg-gray-500 dark:bg-gray-400',
|
||||
icon: 'i-carbon:pause-filled',
|
||||
},
|
||||
};
|
||||
|
||||
// 部署状态徽章组件
|
||||
const DeploymentStatusBadge = ({ status }: { status: string }) => {
|
||||
// 获取状态配置,如果不存在则使用默认配置
|
||||
const config = deploymentStatusConfig[status] || {
|
||||
text: status,
|
||||
bgClass: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
dotClass: 'bg-gray-500 dark:bg-gray-400',
|
||||
icon: 'i-carbon:help',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={classNames('px-2 py-1 text-xs rounded-full inline-flex items-center gap-1', config.bgClass)}>
|
||||
<span className={classNames('size-1.5 rounded-full', config.dotClass)} />
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const isActive = useCallback((status: string) => {
|
||||
return status === DeploymentStatusEnum.SUCCESS || status === DeploymentStatusEnum.DEPLOYED;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<>
|
||||
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={backdropVariants}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[95vw] max-w-[1000px] max-h-[85vh] flex flex-col',
|
||||
)}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={dialogVariants}
|
||||
>
|
||||
<DialogDescription className="sr-only">
|
||||
展示网站部署记录,包括各平台部署的网站数量、运行状态及访问统计等信息。
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
|
||||
<DialogTitle>部署记录</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconButton
|
||||
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
|
||||
onClick={refreshDeploymentRecords}
|
||||
disabled={isLoading}
|
||||
className={classNames(
|
||||
'text-upage-elements-textTertiary hover:text-upage-elements-textSecondary',
|
||||
{
|
||||
'opacity-50 cursor-not-allowed': isLoading,
|
||||
},
|
||||
)}
|
||||
aria-label="刷新统计数据"
|
||||
title="刷新统计数据"
|
||||
/>
|
||||
<RadixDialog.Close asChild onClick={onClose}>
|
||||
<IconButton
|
||||
icon="i-ph:x"
|
||||
className="text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
|
||||
/>
|
||||
</RadixDialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 dark:bg-gray-950/70 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-5 text-upage-elements-textTertiary" />
|
||||
<span className="text-upage-elements-textSecondary font-medium">数据加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className={cardClasses}>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">网站总数</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:globe-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
{stats.totalSites || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<div className="text-sm text-upage-elements-textSecondary mb-1">累计访问量</div>
|
||||
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
|
||||
<span className="i-ph:users-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
|
||||
{stats.totalVisits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-4 flex items-center">
|
||||
<span className="i-ph:list-checks-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
部署详情
|
||||
</h3>
|
||||
|
||||
<Tabs value={activePlatform} onValueChange={handleTabChange} className="mb-4">
|
||||
<TabsList className="w-full border border-upage-elements-borderColor rounded-md p-1 bg-gray-50 dark:bg-gray-900/20 flex">
|
||||
{Object.values(DeploymentPlatformEnum).map((platform, index) => {
|
||||
const count = stats.sitesByPlatform?.[platform] || 0;
|
||||
const isLoading = isPlatformLoading(platform);
|
||||
const isActive = activePlatform === platform;
|
||||
const isLast = index === Object.values(DeploymentPlatformEnum).length - 1;
|
||||
|
||||
return (
|
||||
<div key={platform} className="flex items-center flex-1">
|
||||
<TabsTrigger
|
||||
value={platform}
|
||||
className={classNames(
|
||||
'flex-1 relative py-2 px-3 transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white dark:bg-gray-800 shadow-sm rounded-md text-upage-elements-textPrimary font-medium'
|
||||
: 'hover:bg-gray-100/70 dark:hover:bg-gray-800/30 text-upage-elements-textSecondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={classNames(
|
||||
platformIcons[platform],
|
||||
'size-4',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400' : '',
|
||||
)}
|
||||
/>
|
||||
<span>{platform === DeploymentPlatformEnum._1PANEL ? '1Panel' : platform}</span>
|
||||
{isLoading ? (
|
||||
<span className="i-carbon:circle-dash animate-spin size-3 ml-1 text-purple-500 dark:text-purple-400" />
|
||||
) : (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
|
||||
{count || 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-purple-500 dark:bg-purple-400 rounded-full mx-4" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
{!isLast && (
|
||||
<div className="h-8 w-px bg-upage-elements-borderColor dark:bg-gray-700/50" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
|
||||
<div className="min-h-[400px] max-h-[500px] flex flex-col">
|
||||
<div className="overflow-x-auto h-full relative">
|
||||
<table className="w-full table-fixed">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
|
||||
聊天
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[10%]">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[25%]">
|
||||
部署地址
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
|
||||
部署时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
|
||||
更新时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[20%]">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-upage-elements-borderColor">
|
||||
{isPlatformLoading(activePlatform) && !deploymentRecords[activePlatform]?.length ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="h-[300px]">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-8 mb-2 text-purple-500 dark:text-purple-400" />
|
||||
<span className="text-upage-elements-textSecondary">加载中...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : deploymentRecords[activePlatform]?.length > 0 ? (
|
||||
deploymentRecords[activePlatform].map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/30 transition-colors duration-150"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={`/chat/${record.chatId}`}
|
||||
className="text-blue-500 dark:text-blue-400 hover:underline hover:text-purple-700 dark:hover:text-purple-300 transition-colors"
|
||||
title={record.chat?.description || '未命名聊天'}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="i-ph:chat-circle-text size-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">
|
||||
{record.chat?.description || '未命名聊天'}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<DeploymentStatusBadge status={record.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-blue-500 dark:text-blue-400 text-ellipsis text-nowrap">
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
<span className="i-ph:link size-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">{record.url}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-upage-elements-textSecondary whitespace-nowrap">
|
||||
{formatDate(record.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-upage-elements-textSecondary whitespace-nowrap">
|
||||
{formatDate(record.updatedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<IconButton
|
||||
icon={isActive(record.status) ? 'i-ph:pause-fill' : 'i-ph:play-fill'}
|
||||
onClick={() => handleToggleAccess(record)}
|
||||
className="!text-gray-500 !hover:text-purple-600 dark:!text-gray-400 dark:!hover:text-purple-400"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
|
||||
sideOffset={5}
|
||||
side="top"
|
||||
>
|
||||
{isActive(record.status) ? '停止访问' : '开启访问'}
|
||||
<Tooltip.Arrow
|
||||
className="fill-upage-elements-background-depth-3"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<IconButton
|
||||
icon="i-ph:pencil-duotone"
|
||||
onClick={() => {
|
||||
window.open(`/chat/${record.chatId}`);
|
||||
}}
|
||||
className="!text-gray-500 !hover:text-blue-600 dark:!text-gray-400 dark:!hover:text-blue-400"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
|
||||
sideOffset={5}
|
||||
side="top"
|
||||
>
|
||||
编辑页面
|
||||
<Tooltip.Arrow
|
||||
className="fill-upage-elements-background-depth-3"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<IconButton
|
||||
icon={'i-ph:trash-duotone'}
|
||||
onClick={() => handleDeletePage(record)}
|
||||
className="!text-gray-500 !hover:text-red-600 dark:!text-gray-400 dark:!hover:text-red-400"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
|
||||
sideOffset={5}
|
||||
side="top"
|
||||
>
|
||||
删除页面
|
||||
<Tooltip.Arrow
|
||||
className="fill-upage-elements-background-depth-3"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-4 py-8 h-[300px] text-center text-upage-elements-textSecondary"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="i-ph:cloud-slash-duotone size-8 mb-2 opacity-70" />
|
||||
<span>暂无部署记录</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deploymentRecords[activePlatform]?.length > 0 &&
|
||||
deploymentRecords[activePlatform].length < (totals[activePlatform] || 0) && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={isPlatformLoading(activePlatform)}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md text-sm',
|
||||
'text-upage-elements-textSecondary hover:text-upage-elements-textPrimary',
|
||||
'border border-upage-elements-borderColor hover:border-upage-elements-borderColorHover',
|
||||
'transition-colors',
|
||||
{ 'opacity-50 cursor-not-allowed': isPlatformLoading(activePlatform) },
|
||||
)}
|
||||
>
|
||||
{isPlatformLoading(activePlatform) ? (
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
|
||||
) : (
|
||||
<div className="i-ph:arrow-down size-4" />
|
||||
)}
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={confirmDialogState.isOpen}
|
||||
onClose={closeConfirmDialog}
|
||||
onConfirm={handleConfirmAction}
|
||||
title={
|
||||
confirmDialogState.action === 'toggle-access'
|
||||
? `${confirmDialogState.recordStatus === 'inactive' ? '开启' : '停止'}页面访问`
|
||||
: '删除页面'
|
||||
}
|
||||
description={
|
||||
confirmDialogState.action === 'toggle-access'
|
||||
? `确定要${confirmDialogState.recordStatus === 'inactive' ? '开启' : '停止'}此页面的访问吗?
|
||||
${confirmDialogState.recordStatus === 'inactive' ? '开启之后,可能需要等待一段时间才可访问。' : ''}
|
||||
`
|
||||
: '确定要删除此页面吗?此操作不可撤销。'
|
||||
}
|
||||
confirmLabel={
|
||||
confirmDialogState.action === 'toggle-access'
|
||||
? confirmDialogState.recordStatus === 'inactive'
|
||||
? '开启访问'
|
||||
: '停止访问'
|
||||
: '删除页面'
|
||||
}
|
||||
cancelLabel="取消"
|
||||
variant={confirmDialogState.action === 'delete' ? 'destructive' : 'default'}
|
||||
isLoading={isConfirmationLoading}
|
||||
/>
|
||||
</>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
});
|
||||
159
app/components/editor/EditDialog.tsx
Normal file
159
app/components/editor/EditDialog.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { sendChatMessageStore } from '~/lib/stores/chat-message';
|
||||
import { DefaultEditor } from './editors/DefaultEditor';
|
||||
import type { EditorProps } from './editors/EditorProps';
|
||||
import { IconEditor } from './editors/IconEditor';
|
||||
import { ImageEditor } from './editors/ImageEditor';
|
||||
import { LinkEditor } from './editors/LinkEditor';
|
||||
import { TextEditor } from './editors/TextEditor';
|
||||
|
||||
export interface EditDialogProps {
|
||||
element: HTMLElement;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export type ElementType = 'text' | 'image' | 'link' | 'button' | 'input' | 'icon' | 'other';
|
||||
|
||||
export const getElementType = (element: HTMLElement): ElementType => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
if (tagName === 'img') {
|
||||
return 'image';
|
||||
}
|
||||
if (tagName === 'a') {
|
||||
return 'link';
|
||||
}
|
||||
if (
|
||||
tagName === 'button' ||
|
||||
(tagName === 'div' && element.classList.contains('btn')) ||
|
||||
(tagName === 'span' && element.classList.contains('btn'))
|
||||
) {
|
||||
return 'button';
|
||||
}
|
||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||
return 'input';
|
||||
}
|
||||
if (tagName === 'iconify-icon') {
|
||||
return 'icon';
|
||||
}
|
||||
if (
|
||||
tagName === 'p' ||
|
||||
tagName === 'h1' ||
|
||||
tagName === 'h2' ||
|
||||
tagName === 'h3' ||
|
||||
tagName === 'h4' ||
|
||||
tagName === 'h5' ||
|
||||
tagName === 'h6' ||
|
||||
tagName === 'span'
|
||||
) {
|
||||
return 'text';
|
||||
}
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const getEditorComponent = (elementType: ElementType): [React.FC<EditorProps>, string] => {
|
||||
switch (elementType) {
|
||||
case 'text':
|
||||
return [TextEditor, '编辑文本'];
|
||||
case 'image':
|
||||
return [ImageEditor, '编辑图片'];
|
||||
case 'link':
|
||||
return [LinkEditor, '编辑链接'];
|
||||
case 'icon':
|
||||
return [IconEditor, '更改图标'];
|
||||
default:
|
||||
return [DefaultEditor, '编辑元素'];
|
||||
}
|
||||
};
|
||||
|
||||
export const EditDialog: React.FC<EditDialogProps> = ({ element, onClose }) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const elementType = getElementType(element);
|
||||
const [EditorComponent, title] = getEditorComponent(elementType);
|
||||
|
||||
const onSendPrompt = async (prompt: string, element: HTMLElement) => {
|
||||
const sendChatMessage = sendChatMessageStore.get();
|
||||
|
||||
if (!sendChatMessage) {
|
||||
console.error('发送消息函数未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const elementInfo = {
|
||||
tagName: element.tagName,
|
||||
className: element.className,
|
||||
id: element.id,
|
||||
innerHTML: element.innerHTML,
|
||||
outerHTML: element.outerHTML,
|
||||
};
|
||||
|
||||
try {
|
||||
sendChatMessage({
|
||||
messageContent: prompt,
|
||||
files: [],
|
||||
metadata: {
|
||||
elementInfo,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 500 }}>{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px' }}>
|
||||
<EditorComponent
|
||||
element={element}
|
||||
onClose={onClose}
|
||||
elementType={elementType}
|
||||
title={title}
|
||||
onSendPrompt={onSendPrompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
201
app/components/editor/Editor.tsx
Normal file
201
app/components/editor/Editor.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useChatHistory } from '~/lib/persistence';
|
||||
import type { Section } from '~/types/actions';
|
||||
import type { DocumentProperties, Editor } from '~/types/editor';
|
||||
import { isValidContent } from '~/utils/html-parse';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { throttleWithTrailing } from '~/utils/throttle';
|
||||
import { EditorComponent } from './EditorComponent';
|
||||
|
||||
export interface ScrollPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface EditorUpdate {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type OnChangeCallback = (editor: Editor, pageName: string, html: string) => void;
|
||||
export type OnSaveCallback = () => void;
|
||||
export type OnLoadCallback = (editor: Editor) => void;
|
||||
export type OnReadyCallback = (editor: Editor) => void;
|
||||
|
||||
interface Props {
|
||||
documents?: Record<string, DocumentProperties>;
|
||||
currentPage?: string;
|
||||
currentSection?: Section;
|
||||
editable?: boolean;
|
||||
debounceChange?: number;
|
||||
debounceScroll?: number;
|
||||
autoFocusOnDocumentChange?: boolean;
|
||||
onChange?: OnChangeCallback;
|
||||
onReset?: () => void;
|
||||
onSave?: OnSaveCallback;
|
||||
onLoad?: OnLoadCallback;
|
||||
onReady?: OnReadyCallback;
|
||||
className?: string;
|
||||
settings?: any;
|
||||
}
|
||||
|
||||
export const EditorStudio = memo(
|
||||
({ documents, currentPage, currentSection, autoFocusOnDocumentChange, onChange, onSave, onLoad, onReady }: Props) => {
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
const pendingSectionRef = useRef<Section | null>(null);
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { getLoadProject } = useChatHistory();
|
||||
|
||||
const updateComponents = useCallback((editor: Editor, section: Section) => {
|
||||
if (!editor) {
|
||||
logger.warn('编辑器实例不存在,无法更新组件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!section.domId) {
|
||||
logger.warn('节点ID不存在,无法更新组件');
|
||||
return;
|
||||
}
|
||||
|
||||
const { domId, action, content, sort, rootDomId } = section;
|
||||
// 验证 content 是否有效
|
||||
if (action !== 'remove' && !isValidContent(content)) {
|
||||
logger.warn('内容无效,无法更新组件', JSON.stringify({ action, domId }));
|
||||
return;
|
||||
}
|
||||
if (rootDomId) {
|
||||
editor.scrollToElement(`#${rootDomId}`);
|
||||
}
|
||||
const id = `#${domId}`;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'add':
|
||||
editor.appendContent(id, content, sort);
|
||||
break;
|
||||
case 'update': {
|
||||
editor.updateContent(id, content, sort);
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
editor.deleteContent(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('执行组件操作时出错', JSON.stringify({ error, action, domId }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const throttledSetComponents = useCallback(updateComponents, []);
|
||||
|
||||
const lastSectionRef = useRef<Section | undefined>(undefined);
|
||||
const throttledSetComponentsRef = useRef(throttleWithTrailing(throttledSetComponents, 150));
|
||||
|
||||
function flushPendingUpdate(editor: Editor) {
|
||||
const lastSection = lastSectionRef.current;
|
||||
if (lastSection && lastSection.content) {
|
||||
updateComponents(editor, lastSection);
|
||||
lastSectionRef.current = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditorDocument(editor: Editor, section?: Section) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* 使用节流函数来更新组件内容
|
||||
* 这样可以避免频繁的更新导致编辑器卡顿
|
||||
*/
|
||||
if (section) {
|
||||
lastSectionRef.current = section;
|
||||
throttledSetComponentsRef.current(editor, section);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSection.pageName) {
|
||||
logger.warn('page should not be empty');
|
||||
}
|
||||
|
||||
// section变更时,先执行上一个section的待处理更新
|
||||
flushPendingUpdate(editor);
|
||||
|
||||
// 保存最新的页面属性,确保在节流期间如果有新的更新进来,会使用最新的数据
|
||||
pendingSectionRef.current = currentSection;
|
||||
setEditorDocument(editor, currentSection);
|
||||
}, [currentSection, autoFocusOnDocumentChange]);
|
||||
|
||||
// 确保在组件卸载前应用最后一次更新
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const editor = editorRef.current;
|
||||
const pendingSection = pendingSectionRef.current;
|
||||
|
||||
// 清除保存定时器
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (editor && pendingSection && pendingSection) {
|
||||
// 直接应用最后的更新,不通过节流
|
||||
updateComponents(editor, pendingSection);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEditorReady = useCallback(
|
||||
async (editor: Editor) => {
|
||||
editorRef.current = editor ?? null;
|
||||
if (onReady) {
|
||||
onReady(editor);
|
||||
}
|
||||
},
|
||||
[onSave],
|
||||
);
|
||||
|
||||
const handleAutoSave = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleContentChange = useCallback((pageName: string, html: string) => {
|
||||
if (editorRef.current && onChange) {
|
||||
onChange(editorRef.current, pageName, html);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (editorRef.current && onLoad) {
|
||||
onLoad(editorRef.current);
|
||||
}
|
||||
}, [getLoadProject]);
|
||||
|
||||
return (
|
||||
<EditorComponent
|
||||
currentPage={currentPage}
|
||||
documents={documents}
|
||||
onLoad={handleLoad}
|
||||
onReady={handleEditorReady}
|
||||
onSave={handleAutoSave}
|
||||
onContentChange={handleContentChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
116
app/components/editor/EditorComponent.tsx
Normal file
116
app/components/editor/EditorComponent.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { createRef, useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditorCommands } from '~/lib/hooks';
|
||||
import type { DocumentProperties, Editor } from '~/types/editor';
|
||||
import { EditorController } from './EditorController';
|
||||
import { EditorRender } from './EditorRender';
|
||||
import { PageRender, type PageRenderRef } from './PageRender';
|
||||
|
||||
export interface EditorComponentProps {
|
||||
documents?: Record<string, DocumentProperties>;
|
||||
currentPage?: string;
|
||||
onReady?: (editor: Editor) => void;
|
||||
onLoad?: () => Promise<void>;
|
||||
onContentChange?: (pageName: string, html: string) => void;
|
||||
onSave?: (pageName: string, html: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function EditorComponent(props: EditorComponentProps) {
|
||||
const { documents = {}, currentPage, onReady, onLoad, onContentChange, onSave } = props;
|
||||
|
||||
const controllerRef = useRef<EditorController | null>(null);
|
||||
const lastContentRef = useRef<Record<string, string>>({});
|
||||
const pageRefsRef = useRef<Record<string, RefObject<PageRenderRef>>>({});
|
||||
const currentPageRef = useRef<string | undefined>(currentPage);
|
||||
|
||||
useEditorCommands(controllerRef);
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(documents).forEach((docName) => {
|
||||
if (!pageRefsRef.current[docName]) {
|
||||
pageRefsRef.current[docName] = createRef<PageRenderRef>();
|
||||
}
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new EditorController({
|
||||
getContentElement,
|
||||
getIframeElement,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (controllerRef.current && onReady) {
|
||||
onReady(controllerRef.current);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
currentPageRef.current = currentPage;
|
||||
}, [currentPage]);
|
||||
|
||||
const getContentElement = useCallback((): HTMLElement | null => {
|
||||
const currentPageName = currentPageRef.current ?? 'index';
|
||||
return pageRefsRef.current[currentPageName]?.current?.element ?? null;
|
||||
}, [pageRefsRef]);
|
||||
|
||||
const getIframeElement = useCallback((): HTMLIFrameElement | null => {
|
||||
const currentPageName = currentPageRef.current ?? 'index';
|
||||
return pageRefsRef.current[currentPageName]?.current?.iframe ?? null;
|
||||
}, [pageRefsRef]);
|
||||
|
||||
/**
|
||||
* 执行保存
|
||||
* @param html 要保存的 HTML 内容
|
||||
*/
|
||||
const handleSave = useCallback(
|
||||
(pageName: string, html: string): void => {
|
||||
if (lastContentRef.current[pageName] === html) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastContentRef.current[pageName] = html;
|
||||
if (onSave) {
|
||||
onSave(pageName, html);
|
||||
}
|
||||
},
|
||||
[onSave],
|
||||
);
|
||||
|
||||
const handleContentUpdate = useCallback(
|
||||
(pageName: string, html: string): void => {
|
||||
if (lastContentRef.current[pageName] === html) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onContentChange) {
|
||||
onContentChange(pageName, html);
|
||||
}
|
||||
},
|
||||
[onContentChange],
|
||||
);
|
||||
|
||||
const handleMount = useCallback(async (): Promise<void> => {
|
||||
if (onLoad) {
|
||||
await onLoad();
|
||||
}
|
||||
}, [onLoad]);
|
||||
|
||||
return (
|
||||
<EditorRender onMount={handleMount}>
|
||||
{Object.values(documents).map((document) => (
|
||||
<PageRender
|
||||
isCurrentPage={document.name === currentPage}
|
||||
ref={pageRefsRef.current[document.name]}
|
||||
key={document.name}
|
||||
document={document}
|
||||
onUpdate={handleContentUpdate}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
))}
|
||||
</EditorRender>
|
||||
);
|
||||
}
|
||||
171
app/components/editor/EditorController.tsx
Normal file
171
app/components/editor/EditorController.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Editor, EditorControllerProps } from '~/types/editor';
|
||||
import { executeScript } from '~/utils/execute-scripts';
|
||||
import { isScriptContent } from '~/utils/html-parse';
|
||||
|
||||
export class EditorController implements Editor {
|
||||
private props: EditorControllerProps;
|
||||
|
||||
constructor(props: EditorControllerProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
setContent(newHTML: string) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
pageElement.innerHTML = newHTML;
|
||||
}
|
||||
|
||||
replaceWith(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.outerHTML = newHTML;
|
||||
if (sort === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = targetElement.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const children = Array.from(parent.children);
|
||||
const index = children.indexOf(targetElement);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort !== index) {
|
||||
parent.insertBefore(targetElement, children[sort]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定节点下追加 HTML
|
||||
* @param query 查询条件,待添加元素的父节点 ID。
|
||||
* @param newHTML 新 HTML
|
||||
* @param sort 排序位置,从 0 开始表示应该处于第一位,2 表示应该处于第二位,以此类推。不填写则表示默认或者不变
|
||||
*/
|
||||
append(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
const parent = targetElement || pageElement;
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newHTML;
|
||||
const newElement = tempDiv.firstElementChild as HTMLElement;
|
||||
if (!newElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort === undefined) {
|
||||
parent.appendChild(newElement);
|
||||
} else {
|
||||
parent.insertBefore(newElement, parent.children[sort]);
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newHTML;
|
||||
const newElement = tempDiv.firstElementChild as HTMLElement;
|
||||
if (!newElement) {
|
||||
return;
|
||||
}
|
||||
const id = newElement.id;
|
||||
const targetElement = pageElement.querySelector(`#${id}`);
|
||||
if (targetElement) {
|
||||
this.replaceWith(`#${id}`, newHTML, sort);
|
||||
if (isScriptContent(newHTML)) {
|
||||
this.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.append(query, newHTML, sort);
|
||||
const element = pageElement.querySelector(`#${id}`);
|
||||
if (element instanceof HTMLScriptElement) {
|
||||
executeScript(element);
|
||||
const frameRef = this.props.getIframeElement();
|
||||
const event = new Event('DOMContentLoaded', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
frameRef?.contentDocument?.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
updateContent(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
this.replaceWith(query, newHTML, sort);
|
||||
if (isScriptContent(newHTML)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
deleteContent(query: string) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
if (targetElement) {
|
||||
targetElement.remove();
|
||||
}
|
||||
if (targetElement instanceof HTMLScriptElement) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
getContent(query?: string): string {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
return targetElement ? targetElement.innerHTML : '';
|
||||
}
|
||||
|
||||
return pageElement.innerHTML;
|
||||
}
|
||||
|
||||
scrollToElement(query: string) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
const iframeElement = this.props.getIframeElement();
|
||||
if (!iframeElement) {
|
||||
return;
|
||||
}
|
||||
iframeElement.contentWindow?.location.reload();
|
||||
}
|
||||
}
|
||||
330
app/components/editor/EditorOverlay.tsx
Normal file
330
app/components/editor/EditorOverlay.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useFrame } from 'react-frame-component';
|
||||
import { EditDialog } from './EditDialog';
|
||||
|
||||
export interface EditorOverlayProps {
|
||||
selectedElement: HTMLElement | null;
|
||||
hoveredElement: HTMLElement | null;
|
||||
setHoveredElement: (element: HTMLElement | null) => void;
|
||||
setSelectedElement: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const shadowDomStyles = `
|
||||
.overlay-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 999996;
|
||||
}
|
||||
|
||||
.hover-overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
z-index: 999997;
|
||||
background-color: rgba(0, 102, 255, 0.1);
|
||||
border: 1px dashed rgb(0, 87, 255);
|
||||
}
|
||||
|
||||
.select-overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
z-index: 999997;
|
||||
border: 1px dashed rgb(0, 87, 255);
|
||||
}
|
||||
|
||||
.editor-dialog {
|
||||
position: absolute;
|
||||
z-index: 999998;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
min-width: 320px;
|
||||
pointer-events: auto;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自定义箭头样式 */
|
||||
.floating-arrow {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: rotate(45deg);
|
||||
background: white;
|
||||
z-index: 999997;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 编辑器覆盖层组件,负责在 iframe 内创建和管理覆盖层。
|
||||
* 覆盖层用于操作和修改 HTML 元素。
|
||||
* 为防止样式覆盖,因此使用 Shadow DOM 创建覆盖层。
|
||||
*/
|
||||
export const EditorOverlay: React.FC<EditorOverlayProps> = ({
|
||||
selectedElement,
|
||||
hoveredElement,
|
||||
setHoveredElement,
|
||||
setSelectedElement,
|
||||
}) => {
|
||||
const { document: iframeDocument, window: iframeWindow } = useFrame();
|
||||
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
|
||||
const [selectRect, setSelectRect] = useState<DOMRect | null>(null);
|
||||
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null);
|
||||
|
||||
const { refs: hoverRefs, floatingStyles: hoverFloatingStyles } = useFloating({
|
||||
elements: {
|
||||
reference: hoveredElement ?? undefined,
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(({ rects }) => {
|
||||
return -rects.reference.height / 2 - rects.floating.height / 2;
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { refs: selectRefs, floatingStyles: selectFloatingStyles } = useFloating({
|
||||
elements: {
|
||||
reference: selectedElement ?? undefined,
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(({ rects }) => {
|
||||
return -rects.reference.height / 2 - rects.floating.height / 2;
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
elements: {
|
||||
reference: selectedElement ?? undefined,
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: 'bottom',
|
||||
middleware: [
|
||||
offset(10),
|
||||
flip({
|
||||
fallbackPlacements: ['top'],
|
||||
crossAxis: true,
|
||||
boundary: iframeDocument?.body || undefined,
|
||||
}),
|
||||
shift({
|
||||
padding: 10,
|
||||
limiter: {
|
||||
options: {
|
||||
offset: 100,
|
||||
},
|
||||
fn: (state) => {
|
||||
const { x, y } = state;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { getFloatingProps } = useInteractions([useClick(context), useDismiss(context)]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredElement && hoverRefs.reference.current !== hoveredElement) {
|
||||
hoverRefs.reference.current = hoveredElement;
|
||||
}
|
||||
}, [hoveredElement, hoverRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement && selectRefs.reference.current !== selectedElement) {
|
||||
selectRefs.reference.current = selectedElement;
|
||||
}
|
||||
}, [selectedElement, selectRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement && refs.reference.current !== selectedElement) {
|
||||
refs.reference.current = selectedElement;
|
||||
}
|
||||
}, [selectedElement, refs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = iframeDocument.createElement('div');
|
||||
container.id = 'editor-overlay';
|
||||
|
||||
iframeDocument.body.appendChild(container);
|
||||
const shadow = container.attachShadow({ mode: 'open' });
|
||||
|
||||
const style = iframeDocument.createElement('style');
|
||||
style.textContent = shadowDomStyles;
|
||||
shadow.appendChild(style);
|
||||
|
||||
const contentContainer = iframeDocument.createElement('div');
|
||||
contentContainer.className = 'overlay-container';
|
||||
shadow.appendChild(contentContainer);
|
||||
|
||||
setShadowRoot(shadow);
|
||||
|
||||
return () => {
|
||||
if (container && container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
};
|
||||
}, [iframeDocument, iframeWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (
|
||||
target === iframeDocument.body ||
|
||||
target === iframeDocument.documentElement ||
|
||||
target.closest('#editor-overlay')
|
||||
) {
|
||||
if (hoveredElement) {
|
||||
setHoveredElement(null);
|
||||
setHoverRect(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoveredElement !== target) {
|
||||
setHoveredElement(target);
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
setHoverRect({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
toJSON: rect.toJSON,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === iframeDocument.body || target === iframeDocument.documentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedElement(target);
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
setSelectRect({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
toJSON: rect.toJSON,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseOut = (e: MouseEvent) => {
|
||||
if (!e.relatedTarget || !iframeDocument.contains(e.relatedTarget as Node)) {
|
||||
setHoveredElement(null);
|
||||
setHoverRect(null);
|
||||
}
|
||||
};
|
||||
|
||||
iframeDocument.body.addEventListener('mousemove', handleMouseMove);
|
||||
iframeDocument.body.addEventListener('click', handleClick);
|
||||
iframeDocument.body.addEventListener('submit', handleSubmit);
|
||||
iframeDocument.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
return () => {
|
||||
iframeDocument.body.removeEventListener('mousemove', handleMouseMove);
|
||||
iframeDocument.body.removeEventListener('click', handleClick);
|
||||
iframeDocument.body.removeEventListener('submit', handleSubmit);
|
||||
iframeDocument.removeEventListener('mouseout', handleMouseOut);
|
||||
};
|
||||
}, [
|
||||
iframeDocument,
|
||||
iframeWindow,
|
||||
selectedElement,
|
||||
hoveredElement,
|
||||
setHoveredElement,
|
||||
setSelectedElement,
|
||||
setHoverRect,
|
||||
setSelectRect,
|
||||
]);
|
||||
|
||||
if (!iframeDocument || !shadowRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlayContainer = shadowRoot.querySelector('.overlay-container');
|
||||
|
||||
if (!overlayContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
{hoveredElement && hoverRect && (
|
||||
<div
|
||||
ref={hoverRefs.setFloating}
|
||||
className="hover-overlay"
|
||||
style={{
|
||||
...hoverFloatingStyles,
|
||||
width: `${hoverRect.width}px`,
|
||||
height: `${hoverRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedElement && selectRect && (
|
||||
<div
|
||||
ref={selectRefs.setFloating}
|
||||
className="select-overlay"
|
||||
style={{
|
||||
...selectFloatingStyles,
|
||||
width: `${selectRect.width}px`,
|
||||
height: `${selectRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedElement && (
|
||||
<div ref={refs.setFloating} className="editor-dialog" style={floatingStyles} {...getFloatingProps()}>
|
||||
<EditDialog element={selectedElement} onClose={() => setSelectedElement(null)} />
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
overlayContainer as Element,
|
||||
);
|
||||
};
|
||||
94
app/components/editor/EditorRender.tsx
Normal file
94
app/components/editor/EditorRender.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { forwardRef, useRef } from 'react';
|
||||
import Frame from 'react-frame-component';
|
||||
|
||||
export interface EditorRenderProps {
|
||||
onMount: (iframe: HTMLIFrameElement | null) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTML 来渲染编辑器。并在 HTML 有所变化时,调用更新函数。
|
||||
* 为了保证纯净性,此函数将只考虑渲染 HTML 以及更新,与外部的所有交互无关。
|
||||
*/
|
||||
export const EditorRender = forwardRef<HTMLDivElement, EditorRenderProps>(({ onMount, children }, ref) => {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
// 初始化的 HTML 内容,如果有 HTML 所需的一些外部资源,可以在这里添加。但需要注意的是,导出时,需要将这些资源也导出。
|
||||
const initialContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
.page-iframe {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor-content"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<div className="editor-render w-full h-full relative">
|
||||
<Frame
|
||||
ref={frameRef}
|
||||
initialContent={initialContent}
|
||||
mountTarget="#editor-content"
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="w-full h-full"
|
||||
style={{ border: 'none', margin: 0, padding: 0 }}
|
||||
contentDidMount={() => {
|
||||
onMount(frameRef.current);
|
||||
}}
|
||||
sandbox="allow-scripts allow-same-origin allow-downloads allow-popups allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols"
|
||||
head={
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#editor-content, .frame-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-editing {
|
||||
outline: 2px dashed #3b82f6 !important;
|
||||
outline-offset: -2px;
|
||||
min-height: 1em;
|
||||
position: relative;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div ref={ref} style={{ width: '100%', height: '100%', margin: 0, padding: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
427
app/components/editor/PageRender.tsx
Normal file
427
app/components/editor/PageRender.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import Frame from 'react-frame-component';
|
||||
import type { DocumentProperties } from '~/types/editor';
|
||||
import { executeScripts } from '~/utils/execute-scripts';
|
||||
import { isMac } from '~/utils/os';
|
||||
import { EditorOverlay } from './EditorOverlay';
|
||||
|
||||
export interface PageRenderRef {
|
||||
element: HTMLDivElement | null;
|
||||
iframe: HTMLIFrameElement | null;
|
||||
}
|
||||
|
||||
export interface EditorRenderProps {
|
||||
document: DocumentProperties;
|
||||
onUpdate?: (pageName: string, html: string) => void;
|
||||
onSave?: (pageName: string, html: string) => void;
|
||||
isCurrentPage?: boolean;
|
||||
}
|
||||
|
||||
const pageAnimationVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
zIndex: 1,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
zIndex: 2,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
display: 'none',
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 HTML 来渲染页面。并在当前页面的 HTML 有所变化时,调用更新函数。
|
||||
* 为了保证纯净性,此函数将只考虑渲染 HTML 以及更新,与外部的所有交互无关。
|
||||
*/
|
||||
export const PageRender = forwardRef<PageRenderRef, EditorRenderProps>(
|
||||
({ document, onUpdate, onSave, isCurrentPage }, ref) => {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
const lastContentRef = useRef<string | null>(null);
|
||||
const isMountedRef = useRef<boolean>(false);
|
||||
const documentContentRef = useRef<string>(document.content);
|
||||
const previousSelectedElementRef = useRef<HTMLElement | null>(null);
|
||||
const hasUnsavedChangesRef = useRef<boolean>(false);
|
||||
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
||||
// 解决 react-frame-component 首次加载时可能无法加载的问题。
|
||||
// https://github.com/ryanseddon/react-frame-component/issues/192
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
setShow(true);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
element: contentRef.current,
|
||||
iframe: frameRef.current,
|
||||
};
|
||||
}, [frameRef.current, contentRef.current]);
|
||||
|
||||
const setElementEditable = useCallback((element: HTMLElement, isEditable: boolean) => {
|
||||
if (isEditable) {
|
||||
element.contentEditable = 'true';
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
element.removeAttribute('contenteditable');
|
||||
element.blur();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
documentContentRef.current = document.content;
|
||||
}, [document.content]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!onSave || !hasUnsavedChangesRef.current || !frameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorContent = iframeDocument.getElementById('page-content');
|
||||
if (!editorContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHTML = editorContent.querySelector(`#page-${document.name}`);
|
||||
if (!contentHTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentHTML.innerHTML;
|
||||
onSave(document.name, currentContent);
|
||||
hasUnsavedChangesRef.current = false;
|
||||
}, [onSave, document.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement) {
|
||||
setElementEditable(selectedElement, true);
|
||||
}
|
||||
|
||||
if (previousSelectedElementRef.current !== selectedElement) {
|
||||
setTimeout(() => {
|
||||
handleSave();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (previousSelectedElementRef.current) {
|
||||
setElementEditable(previousSelectedElementRef.current, false);
|
||||
}
|
||||
previousSelectedElementRef.current = selectedElement;
|
||||
}, [selectedElement, setElementEditable, handleSave]);
|
||||
|
||||
const processContentUpdate = useCallback(
|
||||
(contentHTML: Element | null) => {
|
||||
if (!contentHTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentHTML.innerHTML;
|
||||
if (currentContent !== lastContentRef.current) {
|
||||
lastContentRef.current = currentContent;
|
||||
hasUnsavedChangesRef.current = true;
|
||||
if (onUpdate) {
|
||||
onUpdate(document.name, currentContent);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUpdate, document.name],
|
||||
);
|
||||
|
||||
const setupMutationObserver = useCallback(() => {
|
||||
if (!frameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorContent = iframeDocument.getElementById('page-content');
|
||||
if (!editorContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
let updateTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRealChanges = mutations.some(
|
||||
(mutation) => !(mutation.type === 'attributes' && mutation.attributeName === 'contenteditable'),
|
||||
);
|
||||
|
||||
if (!hasRealChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
}
|
||||
|
||||
updateTimer = setTimeout(() => {
|
||||
const contentHTML = editorContent.querySelector(`#page-${document.name}`);
|
||||
if (!contentHTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentHTML.innerHTML;
|
||||
if (currentContent !== lastContentRef.current) {
|
||||
processContentUpdate(contentHTML);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const config: MutationObserverInit = {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributeOldValue: true,
|
||||
characterDataOldValue: true,
|
||||
};
|
||||
|
||||
observer.observe(editorContent, config);
|
||||
observerRef.current = observer;
|
||||
|
||||
return () => {
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
}
|
||||
observer.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, [processContentUpdate]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if ((isMac ? e.metaKey : e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentPage && frameRef.current) {
|
||||
if (frameRef.current.style.display === 'none') {
|
||||
frameRef.current.style.display = 'block';
|
||||
}
|
||||
if (frameRef.current.style.visibility === 'hidden') {
|
||||
frameRef.current.style.visibility = 'visible';
|
||||
}
|
||||
setupMutationObserver();
|
||||
|
||||
// 添加键盘事件监听器
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// 在 iframe 内也添加键盘事件监听器
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (iframeDocument) {
|
||||
iframeDocument.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
} else if (!isCurrentPage && observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
if (frameRef.current) {
|
||||
frameRef.current.style.visibility = 'hidden';
|
||||
frameRef.current.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
if (frameRef.current?.contentDocument) {
|
||||
frameRef.current.contentDocument.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
};
|
||||
}, [isCurrentPage, setupMutationObserver, handleKeyDown]);
|
||||
|
||||
const handleFrameMount = useCallback(() => {
|
||||
isMountedRef.current = true;
|
||||
if (frameRef.current) {
|
||||
if (isCurrentPage || isCurrentPage === undefined) {
|
||||
frameRef.current.style.visibility = 'visible';
|
||||
frameRef.current.style.display = 'block';
|
||||
setupMutationObserver();
|
||||
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (iframeDocument) {
|
||||
iframeDocument.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
} else {
|
||||
frameRef.current.style.visibility = 'hidden';
|
||||
frameRef.current.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (documentContentRef.current) {
|
||||
const iframeDocument = frameRef.current?.contentDocument;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
const editorContent = iframeDocument.getElementById('page-content');
|
||||
if (!editorContent) {
|
||||
return;
|
||||
}
|
||||
initialPageContent();
|
||||
}
|
||||
// 如果 document 的 content 不为空,则设置为初始内容。
|
||||
}, [isCurrentPage, setupMutationObserver, handleKeyDown]);
|
||||
|
||||
const initialPageContent = useCallback(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasUnsavedChangesRef.current = false;
|
||||
lastContentRef.current = documentContentRef.current;
|
||||
contentRef.current.innerHTML = documentContentRef.current;
|
||||
executeScripts(contentRef.current);
|
||||
const event = new Event('DOMContentLoaded', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
frameRef.current?.contentDocument?.dispatchEvent(event);
|
||||
}, [ref, documentContentRef]);
|
||||
|
||||
// 初始化的 HTML 内容,如果有 HTML 所需的一些外部资源,可以在这里添加。但需要注意的是,导出时,需要将这些资源也导出。
|
||||
const initialContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${document.title}</title>
|
||||
<script src="${import.meta.env.BASE_URL}tailwindcss.js"></script>
|
||||
<script src="${import.meta.env.BASE_URL}iconify-icon.min.js"></script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
#page-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page-content"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="page-render w-full h-full absolute"
|
||||
initial="hidden"
|
||||
animate={isCurrentPage ? 'visible' : 'inactive'}
|
||||
variants={pageAnimationVariants}
|
||||
key={document.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
display: isCurrentPage ? 'block' : 'none',
|
||||
position: 'absolute',
|
||||
}}
|
||||
>
|
||||
{show && (
|
||||
<Frame
|
||||
ref={frameRef}
|
||||
initialContent={initialContent}
|
||||
mountTarget="#page-content"
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="page-iframe"
|
||||
loading="lazy"
|
||||
style={{
|
||||
border: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
visibility: isCurrentPage ? 'visible' : 'hidden',
|
||||
display: isCurrentPage ? 'block' : 'none',
|
||||
}}
|
||||
contentDidMount={handleFrameMount}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
|
||||
head={
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.page-editing {
|
||||
outline: 2px dashed #3b82f6 !important;
|
||||
outline-offset: -2px;
|
||||
min-height: 1em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[contenteditable="true"] {
|
||||
cursor: text;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div id={`page-${document.name}`} ref={contentRef}></div>
|
||||
<EditorOverlay
|
||||
selectedElement={selectedElement}
|
||||
hoveredElement={hoveredElement}
|
||||
setHoveredElement={setHoveredElement}
|
||||
setSelectedElement={setSelectedElement}
|
||||
/>
|
||||
</Frame>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
94
app/components/editor/editors/DefaultEditor.tsx
Normal file
94
app/components/editor/editors/DefaultEditor.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import loadingSvg from '../icons/loading.svg?raw';
|
||||
import sendSvg from '../icons/send.svg?raw';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 默认编辑器组件,通用的 HTML 组件。
|
||||
*/
|
||||
export const DefaultEditor: React.FC<EditorProps> = ({ element, onSendPrompt }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSendPrompt = async () => {
|
||||
if (!prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await onSendPrompt(prompt, element);
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('AI 请求失败:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendPrompt();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
borderRadius: '8px',
|
||||
minHeight: '80px',
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px',
|
||||
paddingRight: '2px',
|
||||
}}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述想修改的逻辑或样式..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSendPrompt}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isLoading || !prompt.trim() ? 'default' : 'pointer',
|
||||
color: isLoading || !prompt.trim() ? '#cbd5e1' : '#3b82f6',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
paddingRight: '12px',
|
||||
paddingBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: loadingSvg }} />
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: sendSvg }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
app/components/editor/editors/EditorProps.ts
Normal file
31
app/components/editor/editors/EditorProps.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 编辑器组件的通用接口
|
||||
*/
|
||||
export interface EditorProps {
|
||||
/**
|
||||
* 被编辑的元素
|
||||
*/
|
||||
element: HTMLElement;
|
||||
|
||||
/**
|
||||
* 发送请求到 AI
|
||||
* @param prompt 提示词
|
||||
* @returns
|
||||
*/
|
||||
onSendPrompt: (prompt: string, element: HTMLElement) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 关闭编辑器的回调函数
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* 元素类型
|
||||
*/
|
||||
elementType: string;
|
||||
|
||||
/**
|
||||
* 对话框标题
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
868
app/components/editor/editors/IconEditor.tsx
Normal file
868
app/components/editor/editors/IconEditor.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 生成 iconify 图标的 HTML
|
||||
*
|
||||
* @param icon 图标名称
|
||||
* @param style 样式对象
|
||||
* @returns 包含 iconify 图标的 HTML 字符串
|
||||
*/
|
||||
const iconifyIcon = (icon: string, style: string = '') => {
|
||||
return `<iconify-icon icon="${icon}" ${style ? `style="${style}"` : ''}></iconify-icon>`;
|
||||
};
|
||||
|
||||
const API_BASE_URLS = ['https://api.iconify.design', 'https://api.simplesvg.com', 'https://api.unisvg.com'];
|
||||
|
||||
const API_ENDPOINTS = {
|
||||
COLLECTIONS: '/collections',
|
||||
COLLECTION: '/collection',
|
||||
SEARCH: '/search',
|
||||
};
|
||||
|
||||
// 每页加载的图标数量
|
||||
const ICONS_PER_PAGE = 20;
|
||||
|
||||
interface IconSetInfo {
|
||||
name: string;
|
||||
total?: number;
|
||||
author?: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
license?: {
|
||||
title: string;
|
||||
url?: string;
|
||||
};
|
||||
samples?: string[];
|
||||
height?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个API源获取 iconify 数据
|
||||
*
|
||||
* @param endpoint API端点
|
||||
* @param params URL参数对象
|
||||
* @returns 获取到的数据
|
||||
* @throws 如果所有API源都失败,则抛出错误
|
||||
*/
|
||||
const fetchFromAPI = async (endpoint: string, params: Record<string, string> = {}) => {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
|
||||
const urlSuffix = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
let lastError = null;
|
||||
let lastResponseText = '';
|
||||
|
||||
for (const baseUrl of API_BASE_URLS) {
|
||||
try {
|
||||
const url = `${baseUrl}${urlSuffix}`;
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
lastResponseText = await response.text();
|
||||
console.warn(`API 请求失败: ${url}, 状态码: ${response.status}, 响应: ${lastResponseText.substring(0, 100)}...`);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`从 ${baseUrl}${urlSuffix} 获取数据失败`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const errorMsg = lastResponseText ? `API 返回错误: ${lastResponseText.substring(0, 200)}` : '无法从任何API源获取数据';
|
||||
console.error(errorMsg, lastError);
|
||||
throw new Error(errorMsg);
|
||||
};
|
||||
|
||||
/**
|
||||
* 图标编辑器组件,用于实现 iconify 图标替换。
|
||||
*/
|
||||
export const IconEditor: React.FC<EditorProps> = ({ element, onClose }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentIcon, setCurrentIcon] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [iconSets, setIconSets] = useState<string[]>([]);
|
||||
const [iconSetsInfo, setIconSetsInfo] = useState<Record<string, IconSetInfo>>({});
|
||||
const [selectedIconSet, setSelectedIconSet] = useState<string>('');
|
||||
const [icons, setIcons] = useState<string[]>([]);
|
||||
const [, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const [allIconNames, setAllIconNames] = useState<string[]>([]);
|
||||
const [loadedIconNames, setLoadedIconNames] = useState<string[]>([]);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (element && element.tagName.toLowerCase() === 'iconify-icon') {
|
||||
const iconName = element.getAttribute('icon') || '';
|
||||
setCurrentIcon(iconName);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIconSets = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchFromAPI(API_ENDPOINTS.COLLECTIONS);
|
||||
const prefixes = Object.keys(data);
|
||||
setIconSets(prefixes);
|
||||
setIconSetsInfo(data);
|
||||
|
||||
if (currentIcon) {
|
||||
const [prefix] = currentIcon.split(':');
|
||||
if (prefixes.includes(prefix)) {
|
||||
setSelectedIconSet(prefix);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedIconSet(prefixes[0]);
|
||||
} catch (err) {
|
||||
console.error('获取图标集失败', err);
|
||||
setError('获取图标集失败,请稍后再试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (iconSets.length === 0) {
|
||||
fetchIconSets();
|
||||
}
|
||||
}, [currentIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && loadMoreTriggerRef.current && !isSearching && hasMore) {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !isLoading && !isLoadingMore) {
|
||||
loadMoreIcons();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '0px 0px 100px 0px' },
|
||||
);
|
||||
|
||||
observerRef.current.observe(loadMoreTriggerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasMore, isLoading, isLoadingMore, isSearching, selectedIconSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIconSet) {
|
||||
setPage(1);
|
||||
setIcons([]);
|
||||
setAllIconNames([]);
|
||||
setLoadedIconNames([]);
|
||||
setHasMore(true);
|
||||
|
||||
fetchAllIconNames(selectedIconSet);
|
||||
}
|
||||
}, [selectedIconSet]);
|
||||
|
||||
const fetchAllIconNames = async (prefix: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchFromAPI(API_ENDPOINTS.COLLECTION, {
|
||||
prefix,
|
||||
chars: 'true',
|
||||
aliases: 'true',
|
||||
});
|
||||
const iconNamesSet = new Set<string>();
|
||||
|
||||
if (data.uncategorized && Array.isArray(data.uncategorized)) {
|
||||
data.uncategorized.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
|
||||
if (data.categories && typeof data.categories === 'object') {
|
||||
Object.values(data.categories).forEach((icons: any) => {
|
||||
if (Array.isArray(icons)) {
|
||||
icons.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.icons && Array.isArray(data.icons)) {
|
||||
data.icons.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
|
||||
if (iconNamesSet.size === 0 && typeof data === 'object') {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (typeof data[key] === 'object' && data[key] !== null) {
|
||||
iconNamesSet.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const iconNames = Array.from(iconNamesSet);
|
||||
setAllIconNames(iconNames);
|
||||
if (iconNames.length > 0) {
|
||||
fetchIconsBatch(prefix, iconNames.slice(0, ICONS_PER_PAGE));
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取图标集失败', err instanceof Error ? err.message : String(err));
|
||||
setError('获取图标集失败,请稍后再试');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIconsBatch = async (prefix: string, iconBatch: string[]) => {
|
||||
if (iconBatch.length === 0) {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const iconsParam = iconBatch.join(',');
|
||||
await fetchFromAPI(`/${prefix}.json`, { icons: iconsParam });
|
||||
const newIcons = iconBatch.map((name) => `${prefix}:${name}`);
|
||||
setIcons((prev) => {
|
||||
const updated = [...prev, ...newIcons];
|
||||
return updated;
|
||||
});
|
||||
|
||||
setLoadedIconNames((prev) => {
|
||||
const updated = [...prev, ...iconBatch];
|
||||
|
||||
const hasMoreIcons = updated.length < allIconNames.length;
|
||||
setTimeout(() => {
|
||||
setHasMore(hasMoreIcons);
|
||||
}, 0);
|
||||
return updated;
|
||||
});
|
||||
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
} catch (err) {
|
||||
console.error('获取图标失败', err instanceof Error ? err.message : String(err));
|
||||
setError('获取图标失败,请稍后再试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreIcons = useCallback(() => {
|
||||
if (isLoading || isLoadingMore || !hasMore || isSearching || !selectedIconSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingMore(true);
|
||||
|
||||
const startIndex = loadedIconNames.length;
|
||||
const endIndex = Math.min(startIndex + ICONS_PER_PAGE, allIconNames.length);
|
||||
|
||||
if (startIndex < endIndex) {
|
||||
const nextBatch = allIconNames.slice(startIndex, endIndex);
|
||||
fetchIconsBatch(selectedIconSet, nextBatch);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [isLoading, isLoadingMore, hasMore, isSearching, selectedIconSet, loadedIconNames.length, allIconNames]);
|
||||
|
||||
const searchIcons = async () => {
|
||||
if (!searchTerm.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchFromAPI(API_ENDPOINTS.SEARCH, { query: searchTerm });
|
||||
let results: string[] = [];
|
||||
if (data.icons && Array.isArray(data.icons)) {
|
||||
results = data.icons;
|
||||
} else if (Array.isArray(data)) {
|
||||
results = data;
|
||||
} else if (data.results && Array.isArray(data.results)) {
|
||||
results = data.results;
|
||||
} else if (typeof data === 'object') {
|
||||
const possibleResults = Object.keys(data).filter((key) => typeof data[key] === 'object' && data[key] !== null);
|
||||
if (possibleResults.length > 0) {
|
||||
results = possibleResults;
|
||||
}
|
||||
}
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
console.error('搜索图标失败', err instanceof Error ? err.message : String(err));
|
||||
setError('搜索图标失败,请稍后再试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
searchIcons();
|
||||
}, [searchTerm]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!isLoading && hasMore && !isLoadingMore && selectedIconSet) {
|
||||
loadMoreIcons();
|
||||
return;
|
||||
}
|
||||
setHasMore(false);
|
||||
setIsLoadingMore(false);
|
||||
}, [isLoading, hasMore, isLoadingMore, selectedIconSet, loadMoreIcons]);
|
||||
|
||||
const selectIcon = (iconName: string) => {
|
||||
if (element && element.tagName.toLowerCase() === 'iconify-icon') {
|
||||
element.setAttribute('icon', iconName);
|
||||
setCurrentIcon(iconName);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconSetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedIconSet(e.target.value);
|
||||
setPage(1);
|
||||
setIcons([]);
|
||||
};
|
||||
|
||||
const renderIcons = () => {
|
||||
const iconsToRender = isSearching ? searchResults : icons;
|
||||
|
||||
if (iconsToRender.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '180px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 24px; color: #64748b'),
|
||||
}}
|
||||
style={{ marginBottom: '12px' }}
|
||||
/>
|
||||
<div>加载中...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:mood-sad', 'font-size: 40px; color: #94a3b8'),
|
||||
}}
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
<div>没有找到图标</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px', color: '#94a3b8' }}>
|
||||
{isSearching ? '请尝试其他搜索关键词' : '请选择其他图标集'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(65px, 1fr))',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{iconsToRender.map((iconName) => (
|
||||
<div
|
||||
key={iconName}
|
||||
onClick={() => selectIcon(iconName)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 4px',
|
||||
border: iconName === currentIcon ? '2px solid #3b82f6' : '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: iconName === currentIcon ? '#eff6ff' : '#ffffff',
|
||||
boxShadow:
|
||||
iconName === currentIcon ? '0 1px 3px rgba(59, 130, 246, 0.1)' : '0 1px 2px rgba(0, 0, 0, 0.02)',
|
||||
transition: 'all 0.2s ease',
|
||||
height: '70px',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (iconName !== currentIcon) {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (iconName !== currentIcon) {
|
||||
e.currentTarget.style.backgroundColor = '#ffffff';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.02)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<iconify-icon icon="${iconName}" style="font-size: 26px; color: ${iconName === currentIcon ? '#3b82f6' : '#475569'}"></iconify-icon>`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
color: iconName === currentIcon ? '#3b82f6' : '#64748b',
|
||||
}}
|
||||
>
|
||||
{iconName.split(':')[1] || iconName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', width: '400px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '16px',
|
||||
color: '#475569',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>当前图标:</span>
|
||||
{currentIcon ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #bae6fd',
|
||||
color: '#0284c7',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<iconify-icon icon="${currentIcon}" style="font-size: 16px; margin-right: 6px"></iconify-icon>`,
|
||||
}}
|
||||
></span>
|
||||
{currentIcon}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#94a3b8', fontSize: '14px', fontStyle: 'italic' }}>未选择</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="搜索图标..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
background: isLoading ? '#f1f5f9' : '#f8fafc',
|
||||
border: 'none',
|
||||
borderLeft: '1px solid #e2e8f0',
|
||||
padding: '0 16px',
|
||||
cursor: isLoading ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
color: isLoading ? '#94a3b8' : '#64748b',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:search', 'font-size: 18px'),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isSearching && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#475569',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
选择图标集:
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<select
|
||||
value={selectedIconSet}
|
||||
onChange={handleIconSetChange}
|
||||
disabled={isLoading || iconSets.length === 0}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
paddingRight: '32px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
color: '#1e293b',
|
||||
backgroundColor: '#ffffff',
|
||||
appearance: 'none',
|
||||
cursor: isLoading || iconSets.length === 0 ? 'default' : 'pointer',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{iconSets.length === 0 ? (
|
||||
<option value="">加载中...</option>
|
||||
) : (
|
||||
iconSets.map((prefix) => (
|
||||
<option key={prefix} value={prefix}>
|
||||
{iconSetsInfo[prefix]?.name ? `${iconSetsInfo[prefix].name}` : prefix}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
pointerEvents: 'none',
|
||||
color: '#64748b',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:chevron-down', 'font-size: 16px'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: '#ef4444',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fee2e2',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:alert-circle', 'font-size: 16px; color: currentColor'),
|
||||
}}
|
||||
/>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '10px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
scrollBehavior: 'smooth',
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100;
|
||||
|
||||
if (isNearBottom && hasMore && !isLoading && !isLoadingMore && !isSearching) {
|
||||
loadMoreIcons();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading && icons.length === 0 && !isSearching ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 40px; color: #64748b; opacity: 0.7'),
|
||||
}}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
color: '#64748b',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
正在加载图标集...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderIcons()
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={loadMoreTriggerRef}
|
||||
style={{
|
||||
height: '20px',
|
||||
margin: '20px 0 10px',
|
||||
visibility: hasMore && !isSearching ? 'visible' : 'hidden',
|
||||
display: hasMore && !isSearching ? 'block' : 'none',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{hasMore && !isSearching && !isLoadingMore && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#94a3b8',
|
||||
padding: '5px',
|
||||
border: '1px dashed #e2e8f0',
|
||||
borderRadius: '4px',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
滚动加载更多...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingMore && !isSearching && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 20px; color: #64748b'),
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', color: '#64748b' }}>加载更多图标...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{!isSearching && hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={isLoading || isLoadingMore}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: isLoading || isLoadingMore ? '#f1f5f9' : '#f8fafc',
|
||||
color: isLoading || isLoadingMore ? '#94a3b8' : '#475569',
|
||||
cursor: isLoading || isLoadingMore ? 'default' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!isLoading && !isLoadingMore) {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (!isLoading && !isLoadingMore) {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading || isLoadingMore ? (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 14px; color: #64748b'),
|
||||
}}
|
||||
style={{ marginRight: '4px' }}
|
||||
/>
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:download', 'font-size: 16px'),
|
||||
}}
|
||||
/>
|
||||
加载更多
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSearching && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSearching(false);
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
color: '#475569',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:arrow-left', 'font-size: 16px'),
|
||||
}}
|
||||
/>
|
||||
返回图标集浏览
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
app/components/editor/editors/ImageEditor.tsx
Normal file
248
app/components/editor/editors/ImageEditor.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import loadingSvg from '../icons/loading.svg?raw';
|
||||
import uploadSvg from '../icons/upload.svg?raw';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 图片编辑器组件,用于上传和替换图片。
|
||||
*/
|
||||
export const ImageEditor: React.FC<EditorProps> = ({ element, onClose }) => {
|
||||
const imgElement = element as HTMLImageElement;
|
||||
const [src, setSrc] = useState(imgElement.src);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState<'upload' | 'preview' | 'complete'>('upload');
|
||||
const [previewSrc, setPreviewSrc] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const maxFileSizeMB = window.ENV.MAX_UPLOAD_SIZE_MB || 5;
|
||||
const maxFileSize = maxFileSizeMB * 1024 * 1024;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
|
||||
setError(`文件大小超过限制,最大允许${maxSizeMB}MB`);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setPreviewSrc(event.target.result as string);
|
||||
setStep('preview');
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('文件读取失败');
|
||||
setError('文件读取失败');
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (previewSrc) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 从base64 src 中提取文件数据
|
||||
const base64Data = previewSrc.split(',')[1];
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteArrays = [];
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteArrays.push(byteCharacters.charCodeAt(i));
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteArrays);
|
||||
const blob = new Blob([byteArray], { type: 'image/png' });
|
||||
|
||||
if (blob.size > maxFileSize) {
|
||||
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
|
||||
throw new Error(`文件大小超过限制,最大允许${maxSizeMB}MB`);
|
||||
}
|
||||
|
||||
const fileName = `image_${Date.now()}.png`;
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '上传失败');
|
||||
}
|
||||
|
||||
imgElement.src = result.data.url;
|
||||
setSrc(result.data.url);
|
||||
setStep('complete');
|
||||
onClose();
|
||||
|
||||
setTimeout(() => {
|
||||
setStep('upload');
|
||||
setPreviewSrc(null);
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('文件上传失败', error);
|
||||
setError(error instanceof Error ? error.message : '文件上传失败');
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setStep('upload');
|
||||
setPreviewSrc(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step === 'upload' && (
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '24px 16px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: loadingSvg }} style={{ margin: '0 auto', display: 'block' }} />
|
||||
<p style={{ marginTop: '12px', color: '#64748b' }}>正在上传图片...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: uploadSvg }} style={{ margin: '0 auto', display: 'block' }} />
|
||||
<p style={{ margin: '12px 0 0', color: '#64748b' }}>点击或拖拽图片到此处上传</p>
|
||||
{error && <p style={{ margin: '8px 0 0', color: '#ef4444' }}>{error}</p>}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'preview' && previewSrc && (
|
||||
<div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 500, marginBottom: '12px', color: '#1e293b' }}>预览图片</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: '120px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', marginBottom: '8px' }}>原图</p>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
height: '150px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="原图"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: '120px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', marginBottom: '8px' }}>新图</p>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
height: '150px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt="新图"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? '上传中...' : '替换图片'}
|
||||
</button>
|
||||
{error && <p style={{ margin: '8px 0 0', color: '#ef4444', fontSize: '12px' }}>{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
app/components/editor/editors/LinkEditor.tsx
Normal file
93
app/components/editor/editors/LinkEditor.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 链接编辑器组件,用于编辑链接元素。
|
||||
*/
|
||||
export const LinkEditor: React.FC<EditorProps> = memo(({ element }) => {
|
||||
const linkElement = element as HTMLAnchorElement;
|
||||
const [href, setHref] = useState(linkElement.getAttribute('href') || '');
|
||||
const [content, setContent] = useState(linkElement.innerHTML);
|
||||
const [target, setTarget] = useState(linkElement.target);
|
||||
|
||||
const handleHrefChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newHref = e.target.value;
|
||||
setHref(newHref);
|
||||
linkElement.href = newHref;
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newContent = e.target.value;
|
||||
setContent(newContent);
|
||||
linkElement.innerHTML = newContent;
|
||||
};
|
||||
|
||||
const handleTargetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTarget = e.target.value;
|
||||
setTarget(newTarget);
|
||||
linkElement.target = newTarget;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>链接地址</label>
|
||||
<input
|
||||
type="text"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
value={href}
|
||||
onChange={handleHrefChange}
|
||||
placeholder="https://upage.ai"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>链接文本</label>
|
||||
<input
|
||||
type="text"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="链接文本"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>打开方式</label>
|
||||
<select
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
value={target}
|
||||
onChange={handleTargetChange}
|
||||
>
|
||||
<option value="">当前窗口</option>
|
||||
<option value="_blank">新窗口</option>
|
||||
<option value="_self">当前框架</option>
|
||||
<option value="_parent">父框架</option>
|
||||
<option value="_top">整个窗口</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
92
app/components/editor/editors/TextEditor.tsx
Normal file
92
app/components/editor/editors/TextEditor.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import loadingSvg from '../icons/loading.svg?raw';
|
||||
import sendSvg from '../icons/send.svg?raw';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 文本编辑器组件,用于向 AI 发送修改请求
|
||||
*/
|
||||
export const TextEditor: React.FC<EditorProps> = ({ element, onClose, onSendPrompt }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSendPrompt = async () => {
|
||||
if (!prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSendPrompt(prompt, element);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('AI 请求失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendPrompt();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
borderRadius: '8px',
|
||||
minHeight: '80px',
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px',
|
||||
paddingRight: '2px',
|
||||
}}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述想修改的逻辑或样式..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSendPrompt}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isLoading || !prompt.trim() ? 'default' : 'pointer',
|
||||
color: isLoading || !prompt.trim() ? '#cbd5e1' : '#3b82f6',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
paddingRight: '12px',
|
||||
paddingBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: loadingSvg }} />
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: sendSvg }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
app/components/editor/editors/index.ts
Normal file
5
app/components/editor/editors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { DefaultEditor } from './DefaultEditor';
|
||||
export type { EditorProps } from './EditorProps';
|
||||
export { ImageEditor } from './ImageEditor';
|
||||
export { LinkEditor } from './LinkEditor';
|
||||
export { TextEditor } from './TextEditor';
|
||||
3
app/components/editor/icons/close.svg
Normal file
3
app/components/editor/icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
13
app/components/editor/icons/loading.svg
Normal file
13
app/components/editor/icons/loading.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 12 12"
|
||||
to="360 12 12"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
4
app/components/editor/icons/send.svg
Normal file
4
app/components/editor/icons/send.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" fill="none" />
|
||||
<path fill="currentColor" d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10A10 10 0 0 1 2 12A10 10 0 0 1 12 2M8 7.71v3.34l7.14.95l-7.14.95v3.34L18 12z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 287 B |
3
app/components/editor/icons/upload.svg
Normal file
3
app/components/editor/icons/upload.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.5 20q-2.28 0-3.89-1.57Q1 16.85 1 14.58q0-1.95 1.17-3.48q1.18-1.53 3.08-1.95q.63-2.3 2.5-3.72Q9.63 4 12 4q2.93 0 4.96 2.04Q19 8.07 19 11q1.73.2 2.86 1.5q1.14 1.28 1.14 3q0 1.88-1.31 3.19T18.5 20H13q-.82 0-1.41-.59Q11 18.83 11 18v-5.15L9.4 14.4L8 13l4-4l4 4l-1.4 1.4l-1.6-1.55V18h5.5q1.05 0 1.77-.73q.73-.72.73-1.77t-.73-1.77Q19.55 13 18.5 13H17v-2q0-2.07-1.46-3.54Q14.08 6 12 6Q9.93 6 8.46 7.46Q7 8.93 7 11h-.5q-1.45 0-2.47 1.03Q3 13.05 3 14.5T4.03 17q1.02 1 2.47 1H9v2m3-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
85
app/components/header/ChatDescription.tsx
Normal file
85
app/components/header/ChatDescription.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { useEffect } from 'react';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/lib/hooks';
|
||||
import { useChatHistory } from '~/lib/hooks/useChatHistory';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
|
||||
export function ChatDescription() {
|
||||
const { getChatLatestDescription } = useChatHistory();
|
||||
const description = useStore(webBuilderStore.chatStore.description);
|
||||
|
||||
const {
|
||||
editing,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
currentDescription,
|
||||
toggleEditMode,
|
||||
setCurrentDescription,
|
||||
updateChatDescription,
|
||||
} = useEditChatDescription({
|
||||
initialDescription: getChatLatestDescription() || '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentDescription && description) {
|
||||
setCurrentDescription(description);
|
||||
updateChatDescription(description);
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
if (!currentDescription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{editing ? (
|
||||
<form onSubmit={handleSubmit} className="flex items-center justify-center">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-upage-elements-background-depth-1 text-upage-elements-textPrimary rounded px-2 py-0.5 mr-2 focus:outline-none focus:ring-1 focus:ring-upage-elements-ring"
|
||||
autoFocus
|
||||
value={currentDescription}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: `${Math.max(currentDescription?.length * 9 || 0, 180)}px` }}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<WithTooltip tooltip="保存标题">
|
||||
<div className="flex justify-between items-center p-2 rounded-md bg-upage-elements-item-backgroundAccent">
|
||||
<button
|
||||
type="submit"
|
||||
className="i-ph:check-bold scale-110 hover:text-upage-elements-item-contentAccent"
|
||||
onMouseDown={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{currentDescription}
|
||||
<TooltipProvider>
|
||||
<WithTooltip tooltip="重命名聊天">
|
||||
<div className="flex justify-between items-center p-2 rounded-md bg-upage-elements-item-backgroundAccent ml-2">
|
||||
<button
|
||||
type="button"
|
||||
className="i-ph:pencil-fill scale-110 hover:text-upage-elements-item-contentAccent"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
app/components/header/DeployTo1PanelDialog.tsx
Normal file
201
app/components/header/DeployTo1PanelDialog.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import _1PanelConnection from '~/components/header/connections/_1PanelConnection';
|
||||
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
|
||||
import { _1PanelConnectionStore } from '~/lib/stores/1panel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
interface DeployTo1PanelDialogProps {
|
||||
isOpen: boolean;
|
||||
deploying: boolean;
|
||||
onClose: () => void;
|
||||
onDeploy: (options?: { customDomain?: string; siteId?: number; protocol?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function DeployTo1PanelDialog({ deploying, isOpen, onClose, onDeploy }: DeployTo1PanelDialogProps) {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
|
||||
const connection = useStore(_1PanelConnectionStore);
|
||||
const [is1PanelConnected, setIs1PanelConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
const [proxyProtocol, setProxyProtocol] = useState('http');
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
setIs1PanelConnected(true);
|
||||
if (isOpen && !is1PanelConnected && !showConnectionForm) {
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIs1PanelConnected(false);
|
||||
if (isOpen && !showConnectionForm) {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
}, [connection.isConnect, isOpen, is1PanelConnected]);
|
||||
|
||||
const check1PanelConnection = () => {
|
||||
if (connection.isConnect) {
|
||||
setIs1PanelConnected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async (options?: { customDomain?: string; siteId?: number; protocol?: string }) => {
|
||||
if (!connection.isConnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onDeploy({
|
||||
...options,
|
||||
customDomain: customDomain || undefined,
|
||||
protocol: proxyProtocol,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProtocol = () => {
|
||||
setProxyProtocol(proxyProtocol === 'http' ? 'https' : 'http');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deploying) {
|
||||
onClose();
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deploymentInfo = getDeploymentByPlatform(DeploymentPlatformEnum._1PANEL);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<Dialog.Title className="sr-only">部署到 1Panel</Dialog.Title>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/icons/1panel.png" alt="1Panel" className="size-5" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{is1PanelConnected ? '部署到 1Panel' : '连接 1Panel 服务器'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{is1PanelConnected
|
||||
? '您的项目将被部署到 1Panel。点击"部署"按钮开始部署。'
|
||||
: '需要连接 1Panel 服务器才能部署项目。请在此页面完成连接。'}
|
||||
</p>
|
||||
|
||||
{!is1PanelConnected && (
|
||||
<p className="text-sm font-medium text-amber-600 dark:text-amber-500 mb-6 flex items-center gap-1.5">
|
||||
<span className="i-ph:warning-circle size-4 flex-shrink-0" />
|
||||
仅适用于 1Panel V2 版本
|
||||
</p>
|
||||
)}
|
||||
|
||||
{is1PanelConnected && !deploymentInfo?.id && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">自定义域名(可选)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleProtocol}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[#F8F8F8] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333] text-gray-900 dark:text-white hover:bg-[#F0F0F0] dark:hover:bg-[#222222] transition-colors focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive"
|
||||
>
|
||||
{proxyProtocol}
|
||||
</button>
|
||||
<span className="text-gray-500 dark:text-gray-400">://</span>
|
||||
<input
|
||||
type="text"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
placeholder="example.upage.ai"
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-[#F8F8F8] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333] text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">留空将使用自动生成的域名</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="1panel-connection-wrapper">
|
||||
<_1PanelConnection isDeploying={deploying} onDeploy={(siteId) => handleDeploy({ siteId })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{!is1PanelConnected ? (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
check1PanelConnection();
|
||||
setTimeout(check1PanelConnection, 500);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => handleDeploy()}
|
||||
disabled={deploying}
|
||||
className="px-4 py-2 rounded-lg bg-[#2b5fe3] text-white text-sm hover:bg-[#2b5fe3]/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
{!!deploymentInfo?.id ? '覆盖已有网站' : '部署到 1Panel'}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
163
app/components/header/DeployToNetlifyDialog.tsx
Normal file
163
app/components/header/DeployToNetlifyDialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Suspense, useEffect, useState } from 'react';
|
||||
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
const NetlifyConnection = React.lazy(() => import('~/components/header/connections/NetlifyConnection'));
|
||||
|
||||
interface DeployToNetlifyDialogProps {
|
||||
isOpen: boolean;
|
||||
deploying: boolean;
|
||||
onClose: () => void;
|
||||
onDeploy: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function DeployToNetlifyDialog({ deploying, isOpen, onClose, onDeploy }: DeployToNetlifyDialogProps) {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [isNetlifyConnected, setIsNetlifyConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
setIsNetlifyConnected(true);
|
||||
if (isOpen && !isNetlifyConnected && !showConnectionForm) {
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
} else {
|
||||
setIsNetlifyConnected(false);
|
||||
if (isOpen && !showConnectionForm) {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
}
|
||||
}, [connection.isConnect, isOpen, isNetlifyConnected]);
|
||||
|
||||
const checkNetlifyConnection = () => {
|
||||
if (connection.isConnect) {
|
||||
setIsNetlifyConnected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!connection.isConnect) {
|
||||
return;
|
||||
}
|
||||
await onDeploy();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deploying) {
|
||||
onClose();
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deploymentInfo = getDeploymentByPlatform(DeploymentPlatformEnum.NETLIFY);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<Dialog.Title className="sr-only">部署到 Netlify</Dialog.Title>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[#00AD9F] i-simple-icons:netlify size-5"></div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{isNetlifyConnected ? '部署到 Netlify' : '连接 Netlify 账户'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isNetlifyConnected
|
||||
? '您的项目将被部署到 Netlify。点击"部署"按钮开始部署。'
|
||||
: '需要连接 Netlify 账户才能部署项目。请在此页面完成连接。'}
|
||||
</p>
|
||||
|
||||
<div className="netlify-connection-wrapper">
|
||||
<Suspense>
|
||||
<NetlifyConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{isNetlifyConnected ? (
|
||||
<motion.button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying}
|
||||
className="px-4 py-2 rounded-lg bg-[#00AD9F] text-white text-sm hover:bg-[#009688] inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
{!!deploymentInfo?.id ? '覆盖已有网站' : '部署到 Netlify'}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
checkNetlifyConnection();
|
||||
setTimeout(checkNetlifyConnection, 500);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
159
app/components/header/DeployToVercelDialog.tsx
Normal file
159
app/components/header/DeployToVercelDialog.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import VercelConnection from '~/components/header/connections/VercelConnection';
|
||||
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
interface DeployToVercelDialogProps {
|
||||
isOpen: boolean;
|
||||
deploying: boolean;
|
||||
onClose: () => void;
|
||||
onDeploy: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function DeployToVercelDialog({ deploying, isOpen, onClose, onDeploy }: DeployToVercelDialogProps) {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
const connection = useStore(vercelConnection);
|
||||
const [isVercelConnected, setIsVercelConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.user) {
|
||||
setIsVercelConnected(true);
|
||||
if (isOpen && !isVercelConnected && !showConnectionForm) {
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
} else {
|
||||
setIsVercelConnected(false);
|
||||
if (isOpen && !showConnectionForm) {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
}
|
||||
}, [connection.user, isOpen, isVercelConnected]);
|
||||
|
||||
const checkVercelConnection = () => {
|
||||
if (connection.user) {
|
||||
setIsVercelConnected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!connection.user) {
|
||||
return;
|
||||
}
|
||||
onDeploy();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deploying) {
|
||||
onClose();
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deploymentInfo = getDeploymentByPlatform(DeploymentPlatformEnum.VERCEL);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<Dialog.Title className="sr-only">部署到 Vercel</Dialog.Title>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-skill-icons:vercel-light size-5"></div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{connection.user ? '部署到 Vercel' : '连接 Vercel 账户'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isVercelConnected
|
||||
? '您的项目将被部署到 Vercel。点击"部署"按钮开始部署。'
|
||||
: '需要连接 Vercel 账户才能部署项目。请在此页面完成连接。'}
|
||||
</p>
|
||||
|
||||
<div className="vercel-connection-wrapper">
|
||||
<VercelConnection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{!isVercelConnected ? (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
checkVercelConnection();
|
||||
setTimeout(checkVercelConnection, 500);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying}
|
||||
className="px-4 py-2 rounded-lg bg-black dark:bg-white dark:text-black text-white text-sm hover:bg-gray-800 dark:hover:bg-gray-200 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
{!!deploymentInfo?.id ? '覆盖已有网站' : '部署到 Vercel'}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
58
app/components/header/Header.tsx
Normal file
58
app/components/header/Header.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { useAuth } from '~/lib/hooks';
|
||||
import { aiState } from '~/lib/stores/ai-state';
|
||||
import { HistorySwitch } from '../sidebar/HistorySwitch';
|
||||
import { ThemeSwitch } from '../ui/ThemeSwitch';
|
||||
import { ChatDescription } from './ChatDescription';
|
||||
import { HeaderActionButtons } from './HeaderActionButtons';
|
||||
import { MinimalAvatarDropdown } from './MinimalAvatarDropdown';
|
||||
|
||||
export function Header() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { chatStarted } = useStore(aiState);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={classNames(
|
||||
'flex items-center justify-between px-3 py-2 gap-3 shrink-0 border-b h-[var(--header-height)]',
|
||||
{
|
||||
'border-transparent': !chatStarted,
|
||||
'border-upage-elements-borderColor': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 z-logo text-upage-elements-textPrimary cursor-pointer">
|
||||
<a href="/" className="text-xl font-semibold text-accent flex items-center">
|
||||
UPage
|
||||
</a>
|
||||
<div className="flex gap-1 ml-6">
|
||||
{isAuthenticated && <HistorySwitch />}
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 truncate text-center text-upage-elements-textPrimary">
|
||||
{chatStarted && <ClientOnly>{() => <ChatDescription />}</ClientOnly>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{chatStarted && (
|
||||
<>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)}
|
||||
<MinimalAvatarDropdown />
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
442
app/components/header/HeaderActionButtons.tsx
Normal file
442
app/components/header/HeaderActionButtons.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { toast } from 'sonner';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { setLocalStorage } from '~/lib/persistence';
|
||||
import { aiState, setShowChat } from '~/lib/stores/ai-state';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import type { _1PanelDeployResponse } from '~/types/1panel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import { _1PanelDeploymentLink } from '../chat/_1PanelDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '../chat/VercelDeploymentLink.client';
|
||||
import { UPageIndex } from '../upage/Index';
|
||||
import { DeployTo1PanelDialog } from './DeployTo1PanelDialog';
|
||||
import { DeployToNetlifyDialog } from './DeployToNetlifyDialog';
|
||||
import { DeployToVercelDialog } from './DeployToVercelDialog';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(webBuilderStore.showWorkbench);
|
||||
const { showChat, chatId, isStreaming } = useStore(aiState);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | '1panel' | null>(null);
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const netlifyFetcher = useFetcher<ApiResponse>();
|
||||
const vercelFetcher = useFetcher<ApiResponse>();
|
||||
const panelFetcher = useFetcher<_1PanelDeployResponse>();
|
||||
|
||||
const isDeploying = useMemo(() => {
|
||||
return netlifyFetcher.state !== 'idle' || vercelFetcher.state !== 'idle' || panelFetcher.state !== 'idle';
|
||||
}, [netlifyFetcher.state, vercelFetcher.state, panelFetcher.state]);
|
||||
|
||||
const [showNetlifyDialog, setShowNetlifyDialog] = useState(false);
|
||||
const [showVercelDialog, setShowVercelDialog] = useState(false);
|
||||
const [show1PanelDialog, setShow1PanelDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const deploy = url.searchParams.get('deploy');
|
||||
switch (deploy) {
|
||||
case DeploymentPlatformEnum.NETLIFY:
|
||||
setShowNetlifyDialog(true);
|
||||
break;
|
||||
case DeploymentPlatformEnum.VERCEL:
|
||||
setShowVercelDialog(true);
|
||||
break;
|
||||
case DeploymentPlatformEnum._1PANEL:
|
||||
setShow1PanelDialog(true);
|
||||
break;
|
||||
}
|
||||
const recommend = url.searchParams.get('recommend');
|
||||
if (recommend) {
|
||||
setLocalStorage('recommend', recommend || '');
|
||||
}
|
||||
if (deploy || recommend) {
|
||||
url.searchParams.delete('deploy');
|
||||
url.searchParams.delete('recommend');
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (netlifyFetcher.state === 'idle' && netlifyFetcher.data) {
|
||||
const { data, success, message } = netlifyFetcher.data;
|
||||
|
||||
if (success && data?.deploy && data?.site) {
|
||||
if (data.site) {
|
||||
localStorage.setItem(`netlify-site-${chatId!}`, data.site?.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署成功!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShowNetlifyDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [netlifyFetcher.state, netlifyFetcher.data, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (vercelFetcher.state === 'idle' && vercelFetcher.data) {
|
||||
const { data, success, message } = vercelFetcher.data;
|
||||
|
||||
if (success && data?.deploy && data?.project) {
|
||||
if (data.project) {
|
||||
localStorage.setItem(`vercel-project-${chatId!}`, data.project.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署到 Vercel 成功!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShowVercelDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [vercelFetcher.state, vercelFetcher.data, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (panelFetcher.state === 'idle' && panelFetcher.data) {
|
||||
const data = panelFetcher.data as _1PanelDeployResponse;
|
||||
|
||||
const { deploy } = data.data || {};
|
||||
if (data.success && deploy) {
|
||||
localStorage.setItem(`1panel-project-${chatId!}`, deploy.id.toString());
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署到 1Panel 成功!{' '}
|
||||
<a href={deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShow1PanelDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(data.message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [panelFetcher.state, panelFetcher.data, chatId]);
|
||||
|
||||
async function getAllFiles(): Promise<Record<string, string>> {
|
||||
const files = await webBuilderStore.getProjectFiles({ inline: false }).then((files) => {
|
||||
return files.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.filename] = file.content;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
});
|
||||
const newFiles: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(files)) {
|
||||
if (key.endsWith('.html')) {
|
||||
const html = new DOMParser().parseFromString(value, 'text/html');
|
||||
const originalContent = html.body.innerHTML;
|
||||
// 添加 UPageHtml 到 body 中
|
||||
const uPageHtml = renderToStaticMarkup(<UPageIndex />);
|
||||
html.body.innerHTML = originalContent + uPageHtml;
|
||||
newFiles[key] = '<!DOCTYPE html>\n' + html.documentElement.outerHTML;
|
||||
} else {
|
||||
newFiles[key] = value;
|
||||
}
|
||||
}
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
const handleNetlifyDeploy = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('netlify');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${chatId}`);
|
||||
|
||||
netlifyFetcher.submit(
|
||||
{
|
||||
siteId: existingSiteId || '',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/netlify/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : '部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [chatId, netlifyFetcher]);
|
||||
|
||||
const handleVercelDeploy = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('vercel');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingProjectId = localStorage.getItem(`vercel-project-${chatId}`);
|
||||
|
||||
vercelFetcher.submit(
|
||||
{
|
||||
projectId: existingProjectId || '',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/vercel/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Vercel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Vercel 部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [chatId, vercelFetcher]);
|
||||
|
||||
const handle1PanelDeploy = useCallback(
|
||||
async (options?: { customDomain?: string; siteId?: number; protocol?: string }) => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('1panel');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingWebsiteId = localStorage.getItem(`1panel-project-${chatId}`);
|
||||
|
||||
panelFetcher.submit(
|
||||
{
|
||||
websiteId: options?.siteId || existingWebsiteId || '',
|
||||
websiteDomain: options?.customDomain || '',
|
||||
protocol: options?.protocol || 'http',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/1panel/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('1Panel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : '1Panel 部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
},
|
||||
[chatId, panelFetcher],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="flex border border-upage-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || isStreaming}
|
||||
onClick={() => {
|
||||
if (isDeploying || isStreaming) {
|
||||
return;
|
||||
}
|
||||
setShow1PanelDialog(true);
|
||||
}}
|
||||
className="px-4 hover:bg-upage-elements-item-backgroundActive flex items-center gap-2"
|
||||
>
|
||||
<div className="i-mingcute:rocket-line size-4" />
|
||||
{isDeploying ? `部署至 ${deployingTo} 中...` : '部署'}
|
||||
</Button>
|
||||
<div className="w-[1px] bg-upage-elements-borderColor" />
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || isStreaming}
|
||||
onClick={() => {
|
||||
if (isDeploying || isStreaming) {
|
||||
return;
|
||||
}
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames('i-ph:caret-down size-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[14rem] bg-upage-elements-background-depth-2 rounded-md shadow-lg bg-upage-elements-backgroundDefault border border-upage-elements-borderColor">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShow1PanelDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<img src="/icons/1panel.png" alt="1Panel" className="size-5" />
|
||||
<span>部署到 1Panel</span>
|
||||
<_1PanelDeploymentLink />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowNetlifyDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<div className="i-simple-icons:netlify size-5 bg-#00C7B7"></div>
|
||||
<span>部署到 Netlify</span>
|
||||
<NetlifyDeploymentLink />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowVercelDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<div className="i-skill-icons:vercel-light size-5"></div>
|
||||
<span>部署到 Vercel</span>
|
||||
<VercelDeploymentLink />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex border border-upage-elements-borderColor rounded-md overflow-hidden mr-2">
|
||||
<Button
|
||||
active={showChat}
|
||||
disabled={!canHideChat || isSmallViewport}
|
||||
onClick={() => {
|
||||
if (canHideChat) {
|
||||
setShowChat(!showChat);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:chat-2-line text-sm" />
|
||||
</Button>
|
||||
<div className="w-[1px] bg-upage-elements-borderColor" />
|
||||
<Button
|
||||
active={showWorkbench}
|
||||
onClick={() => {
|
||||
if (showWorkbench && !showChat) {
|
||||
setShowChat(true);
|
||||
}
|
||||
|
||||
webBuilderStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:code-line" />
|
||||
</Button>
|
||||
</div>
|
||||
<DeployToNetlifyDialog
|
||||
isOpen={showNetlifyDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShowNetlifyDialog(false)}
|
||||
onDeploy={handleNetlifyDeploy}
|
||||
/>
|
||||
<DeployToVercelDialog
|
||||
isOpen={showVercelDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShowVercelDialog(false)}
|
||||
onDeploy={handleVercelDeploy}
|
||||
/>
|
||||
<DeployTo1PanelDialog
|
||||
isOpen={show1PanelDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShow1PanelDialog(false)}
|
||||
onDeploy={handle1PanelDeploy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: any;
|
||||
onClick?: VoidFunction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center p-1.5',
|
||||
{
|
||||
'bg-upage-elements-item-backgroundDefault hover:bg-upage-elements-item-backgroundAccent text-upage-elements-textPrimary hover:text-upage-elements-item-contentAccent':
|
||||
!active,
|
||||
'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent': active && !disabled,
|
||||
'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent cursor-not-allowed':
|
||||
active && disabled,
|
||||
'bg-upage-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||
!active && disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
194
app/components/header/MinimalAvatarDropdown.tsx
Normal file
194
app/components/header/MinimalAvatarDropdown.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChatUsageDialog } from '~/components/chat/usage/ChatUsageDialog';
|
||||
import { DeploymentRecordsDialog } from '~/components/chat/usage/DeploymentRecordsDialog';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { ConfirmationDialog } from '~/components/ui/Dialog';
|
||||
import { useAuth } from '~/lib/hooks/useAuth';
|
||||
import { useChatUsage } from '~/lib/hooks/useChatUsage';
|
||||
|
||||
interface MinimalAvatarDropdownProps {}
|
||||
|
||||
export const MinimalAvatarDropdown = ({}: MinimalAvatarDropdownProps) => {
|
||||
const { userInfo, isAuthenticated, signOut, signIn } = useAuth();
|
||||
|
||||
const { usageStats } = useChatUsage();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Button variant="secondary" onClick={() => signIn()}>
|
||||
登录 / 注册
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return 'Guest User';
|
||||
}
|
||||
|
||||
return userInfo.name || userInfo.username;
|
||||
}, [userInfo]);
|
||||
|
||||
const contactInfo = useMemo(() => {
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userInfo.phone_number) {
|
||||
return `+${userInfo.phone_number}`;
|
||||
}
|
||||
|
||||
return userInfo.email;
|
||||
}, [userInfo]);
|
||||
|
||||
const avatarUrl = isAuthenticated && userInfo?.picture ? userInfo.picture : '';
|
||||
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const [showUsageDialog, setShowUsageDialog] = useState(false);
|
||||
const [showDeploymentRecordsDialog, setShowDeploymentRecordsDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatUsageDialog isOpen={showUsageDialog} onClose={() => setShowUsageDialog(false)} />
|
||||
<DeploymentRecordsDialog
|
||||
isOpen={showDeploymentRecordsDialog}
|
||||
onClose={() => setShowDeploymentRecordsDialog(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={showLogoutConfirm}
|
||||
onClose={() => setShowLogoutConfirm(false)}
|
||||
title="退出登录?"
|
||||
description="退出登录后,您需要重新登录才能继续使用。"
|
||||
confirmLabel="退出登录"
|
||||
cancelLabel="取消"
|
||||
variant="destructive"
|
||||
onConfirm={() => signOut()}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<motion.button
|
||||
className="size-8 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className="size-full rounded-full object-cover select-none"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
||||
<div className="i-ph:user-circle-fill size-8" />
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
'min-w-[240px] z-[250]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-gray-200/50 dark:border-gray-800/50',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'p-1.5 space-y-1.5',
|
||||
)}
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
>
|
||||
<div className={classNames('px-4 py-3 flex items-center gap-3')}>
|
||||
<div className="size-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
|
||||
{Boolean(avatarUrl) ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className={classNames('size-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
||||
<div className="i-ph:user-circle-fill size-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">{displayName}</div>
|
||||
{!!userInfo?.email && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{contactInfo}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Separator className="h-1px bg-gray-100 dark:bg-gray-800" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
'rounded-md',
|
||||
)}
|
||||
onClick={() => setShowUsageDialog(true)}
|
||||
>
|
||||
<div className="i-ph:chart-line size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
<div className="flex-1">API 使用量</div>
|
||||
{usageStats && (
|
||||
<div className="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-300">
|
||||
{usageStats.total._count}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
'rounded-md',
|
||||
)}
|
||||
onClick={() => setShowDeploymentRecordsDialog(true)}
|
||||
>
|
||||
<div className="i-ph:globe size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
<div className="flex-1">部署记录</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
'rounded-md',
|
||||
)}
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
>
|
||||
<div className="i-ph:sign-out size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
退出登录
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
957
app/components/header/connections/GithubConnection.tsx
Normal file
957
app/components/header/connections/GithubConnection.tsx
Normal file
@@ -0,0 +1,957 @@
|
||||
import classNames from 'classnames';
|
||||
import Cookies from 'js-cookie';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import ConnectionBorder from './components/ConnectionBorder';
|
||||
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
public_gists: number;
|
||||
}
|
||||
|
||||
interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
repo: {
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GitHubLanguageStats {
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
publicRepos: number;
|
||||
privateRepos: number;
|
||||
stars: number;
|
||||
forks: number;
|
||||
followers: number;
|
||||
publicGists: number;
|
||||
privateGists: number;
|
||||
lastUpdated: string;
|
||||
|
||||
// Keep these for backward compatibility
|
||||
totalStars?: number;
|
||||
totalForks?: number;
|
||||
organizations?: GitHubOrganization[];
|
||||
}
|
||||
|
||||
interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
rateLimit?: {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function GitHubConnection() {
|
||||
const [connection, setConnection] = useState<GitHubConnection>({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
||||
const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic');
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
console.log('正在获取 GitHub 用户,使用令牌:', token.substring(0, 5) + '...');
|
||||
|
||||
// Use server-side API endpoint instead of direct GitHub API call
|
||||
const response = await fetch(`/api/system/git-info?action=getUser`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`, // Include token in headers for validation
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('获取 GitHub 用户时出错。状态:', response.status);
|
||||
throw new Error(`错误: ${response.status}`);
|
||||
}
|
||||
|
||||
// Get rate limit information from headers
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
console.log('GitHub 用户 API 响应:', data);
|
||||
|
||||
const { user } = data as { user: GitHubUserResponse };
|
||||
|
||||
// Validate that we received a user object
|
||||
if (!user || !user.login) {
|
||||
console.error('收到无效的用户数据:', user);
|
||||
throw new Error('收到无效的用户数据');
|
||||
}
|
||||
|
||||
// Use the response data
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
rateLimit,
|
||||
}));
|
||||
|
||||
// Set cookies for client-side access
|
||||
Cookies.set('githubUsername', user.login);
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
// Store connection details in localStorage
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('已连接到 GitHub', {
|
||||
type: 'system',
|
||||
message: `已连接到 GitHub,用户: ${user.login}`,
|
||||
});
|
||||
|
||||
// Fetch additional GitHub stats
|
||||
fetchGitHubStats(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub user:', error);
|
||||
logStore.logError(`GitHub 认证失败: ${error instanceof Error ? error.message : '未知错误'}`, {
|
||||
type: 'system',
|
||||
message: 'GitHub 认证失败',
|
||||
});
|
||||
|
||||
toast.error(`认证失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
throw error; // Rethrow to allow handling in the calling function
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
setIsFetchingStats(true);
|
||||
|
||||
try {
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const userResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
if (userResponse.status === 401) {
|
||||
toast.error('您的 GitHub 令牌已过期。请重新连接您的账户。');
|
||||
handleDisconnect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch user data: ${userResponse.statusText}`);
|
||||
}
|
||||
|
||||
const userData = (await userResponse.json()) as any;
|
||||
|
||||
// Fetch repositories with pagination
|
||||
let allRepos: any[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reposResponse.ok) {
|
||||
throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`);
|
||||
}
|
||||
|
||||
const repos = (await reposResponse.json()) as any[];
|
||||
allRepos = [...allRepos, ...repos];
|
||||
|
||||
// Check if there are more pages
|
||||
const linkHeader = reposResponse.headers.get('Link');
|
||||
hasMore = linkHeader?.includes('rel="next"') ?? false;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const repoStats = await calculateRepoStats(allRepos);
|
||||
|
||||
// Fetch recent activity
|
||||
const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const events = (await eventsResponse.json()) as any[];
|
||||
const recentActivity = events.slice(0, 5).map((event: any) => ({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
repo: event.repo.name,
|
||||
created_at: event.created_at,
|
||||
}));
|
||||
|
||||
// Calculate total stars and forks
|
||||
const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0);
|
||||
const privateRepos = allRepos.filter((repo: any) => repo.private).length;
|
||||
|
||||
// Update the stats in the store
|
||||
const stats: GitHubStats = {
|
||||
repos: repoStats.repos,
|
||||
recentActivity,
|
||||
languages: repoStats.languages || {},
|
||||
totalGists: repoStats.totalGists || 0,
|
||||
publicRepos: userData.public_repos || 0,
|
||||
privateRepos: privateRepos || 0,
|
||||
stars: totalStars || 0,
|
||||
forks: totalForks || 0,
|
||||
followers: userData.followers || 0,
|
||||
publicGists: userData.public_gists || 0,
|
||||
privateGists: userData.private_gists || 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
|
||||
// For backward compatibility
|
||||
totalStars: totalStars || 0,
|
||||
totalForks: totalForks || 0,
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}');
|
||||
const currentUser = currentConnection.user || connection.user;
|
||||
|
||||
// Update connection with stats
|
||||
const updatedConnection: GitHubConnection = {
|
||||
user: currentUser,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
stats,
|
||||
rateLimit: connection.rateLimit,
|
||||
};
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
|
||||
|
||||
// Update state
|
||||
setConnection(updatedConnection);
|
||||
|
||||
toast.success('GitHub 统计已刷新');
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stats:', error);
|
||||
toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsFetchingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRepoStats = async (
|
||||
repos: any[],
|
||||
): Promise<{ repos: GitHubRepoInfo[]; languages: GitHubLanguageStats; totalGists: number }> => {
|
||||
// 构建基本仓库信息
|
||||
const repoStats = {
|
||||
repos: repos.map((repo: any) => ({
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
html_url: repo.html_url,
|
||||
description: repo.description,
|
||||
stargazers_count: repo.stargazers_count,
|
||||
forks_count: repo.forks_count,
|
||||
default_branch: repo.default_branch,
|
||||
updated_at: repo.updated_at,
|
||||
languages_url: repo.languages_url,
|
||||
})),
|
||||
|
||||
languages: {} as Record<string, number>,
|
||||
totalGists: 0,
|
||||
};
|
||||
|
||||
// 首先使用仓库的主要语言属性构建基本的语言统计
|
||||
repos.forEach((repo: any) => {
|
||||
if (repo.language) {
|
||||
if (!repoStats.languages[repo.language]) {
|
||||
repoStats.languages[repo.language] = 0;
|
||||
}
|
||||
repoStats.languages[repo.language] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const topRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count).slice(0, 10);
|
||||
|
||||
try {
|
||||
const batchSize = 3;
|
||||
for (let i = 0; i < topRepos.length; i += batchSize) {
|
||||
const batch = topRepos.slice(i, i + batchSize);
|
||||
|
||||
const batchPromises = batch.map((repo) =>
|
||||
fetch(repo.languages_url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
console.warn('GitHub API rate limit exceeded when fetching languages');
|
||||
throw new Error('Rate limit exceeded');
|
||||
}
|
||||
throw new Error(`Error fetching languages: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((languages: any) => {
|
||||
const typedLanguages = languages as Record<string, number>;
|
||||
Object.keys(typedLanguages).forEach((language) => {
|
||||
if (!repoStats.languages[language]) {
|
||||
repoStats.languages[language] = 0;
|
||||
}
|
||||
repoStats.languages[language] += 1;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error processing languages for ${repo.name}:`, error);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
|
||||
if (i + batchSize < topRepos.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching repository languages:', error);
|
||||
}
|
||||
|
||||
return repoStats;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedConnection = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
// Update the ref with the parsed token type
|
||||
tokenTypeRef.current = parsed.tokenType;
|
||||
|
||||
// Set the connection
|
||||
setConnection(parsed);
|
||||
|
||||
// If we have a token but no stats or incomplete stats, fetch them
|
||||
if (
|
||||
parsed.user &&
|
||||
parsed.token &&
|
||||
(!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0)
|
||||
) {
|
||||
console.log('Fetching missing GitHub stats for saved connection');
|
||||
await fetchGitHubStats(parsed.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved GitHub connection:', error);
|
||||
localStorage.removeItem('github_connection');
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadSavedConnection();
|
||||
}, []);
|
||||
|
||||
// Ensure cookies are updated when connection changes
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = connection.token;
|
||||
const data = connection.user;
|
||||
|
||||
if (token) {
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
}
|
||||
|
||||
if (data) {
|
||||
Cookies.set('githubUsername', data.login);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// Add function to update rate limits
|
||||
const updateRateLimits = async (token: string) => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/rate_limit', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
rateLimit,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rate limits:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add effect to update rate limits periodically
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (connection.token && connection.user) {
|
||||
updateRateLimits(connection.token);
|
||||
interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [connection.token, connection.user]);
|
||||
|
||||
if (isLoading || isConnecting || isFetchingStats) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
// Update the ref with the current state value before connecting
|
||||
tokenTypeRef.current = connection.tokenType;
|
||||
|
||||
/*
|
||||
* Save token type to localStorage even before connecting
|
||||
* This ensures the token type is persisted even if connection fails
|
||||
*/
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user: null,
|
||||
token: connection.token,
|
||||
tokenType: connection.tokenType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Attempt to fetch the user info which validates the token
|
||||
await fetchGithubUser(connection.token);
|
||||
|
||||
toast.success('已成功连接到 GitHub');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to GitHub:', error);
|
||||
|
||||
// Reset connection state on failure
|
||||
setConnection({ user: null, token: connection.token, tokenType: connection.tokenType });
|
||||
|
||||
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
localStorage.removeItem('github_connection');
|
||||
|
||||
// Remove all GitHub-related cookies
|
||||
Cookies.remove('githubToken');
|
||||
Cookies.remove('githubUsername');
|
||||
Cookies.remove('git:github.com');
|
||||
|
||||
// Reset the token type ref
|
||||
tokenTypeRef.current = 'classic';
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
toast.success('已断开与 GitHub 的连接');
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
{!isConnecting && !connection.user && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
|
||||
令牌类型
|
||||
</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) => {
|
||||
const newTokenType = e.target.value as 'classic' | 'fine-grained';
|
||||
tokenTypeRef.current = newTokenType;
|
||||
setConnection((prev) => ({ ...prev, tokenType: newTokenType }));
|
||||
}}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1',
|
||||
'border border-upage-elements-borderColor dark:border-upage-elements-borderColor',
|
||||
'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`输入您的 GitHub ${
|
||||
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
||||
}`}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1',
|
||||
'border border-upage-elements-borderColor dark:border-upage-elements-borderColor',
|
||||
'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary placeholder-upage-elements-textTertiary dark:placeholder-upage-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-upage-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-upage-elements-link-text dark:text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:hover:text-upage-elements-link-textHover flex items-center gap-1"
|
||||
>
|
||||
<div className="i-ph:key size-4" />
|
||||
获取您的令牌
|
||||
<div className="i-ph:arrow-square-out size-3" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
需要的权限:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{!connection.user ? (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
variant="default"
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-upage-elements-button-secondary-background',
|
||||
'hover:bg-upage-elements-button-secondary-backgroundHover',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:github-logo size-4" />
|
||||
连接
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:check-circle size-4 text-upage-elements-icon-success dark:text-upage-elements-icon-success" />
|
||||
<span className="text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
已连接到 GitHub 使用{' '}
|
||||
<span className="text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent font-medium">
|
||||
{connection.tokenType === 'classic' ? 'PAT' : 'Fine-grained Token'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{connection.rateLimit && (
|
||||
<div className="flex items-center gap-2 text-xs text-upage-elements-textSecondary">
|
||||
<div className="i-ph:chart-line-up w-3.5 h-3.5 text-upage-elements-icon-success" />
|
||||
<span>
|
||||
API 限制: {connection.rateLimit.remaining.toLocaleString()}/
|
||||
{connection.rateLimit.limit.toLocaleString()} • 重置时间:
|
||||
{Math.max(0, Math.floor((connection.rateLimit.reset * 1000 - Date.now()) / 60000))} min
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-mingcute:dashboard-line size-4" />
|
||||
仪表盘
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
fetchGitHubStats(connection.token);
|
||||
updateRateLimits(connection.token);
|
||||
}}
|
||||
disabled={isFetchingStats}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
|
||||
>
|
||||
{isFetchingStats ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap size-4 animate-spin" />
|
||||
刷新...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:arrows-clockwise size-4" />
|
||||
刷新统计
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
|
||||
<div className="i-ph:sign-out size-4" />
|
||||
断开连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-upage-elements-borderColor dark:border-upage-elements-borderColor pt-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1 rounded-lg mb-4">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="size-12 rounded-full border-2 border-upage-elements-item-contentAccent dark:border-upage-elements-item-contentAccent"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h4>
|
||||
<p className="text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
{connection.user.login}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200 cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary">GitHub 统计</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
|
||||
isStatsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-upage-elements-sidebar-buttonBackgroundDefault text-upage-elements-sidebar-buttonText"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{
|
||||
label: 'Member Since',
|
||||
value: new Date(connection.user.created_at).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
label: 'Public Gists',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
value: connection.stats.organizations ? connection.stats.organizations.length : 0,
|
||||
},
|
||||
{
|
||||
label: 'Languages',
|
||||
value: Object.keys(connection.stats.languages).length,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Repository Stats</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public Repos',
|
||||
value: connection.stats.publicRepos,
|
||||
},
|
||||
{
|
||||
label: 'Private Repos',
|
||||
value: connection.stats.privateRepos,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Contribution Stats</h5>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Stars',
|
||||
value: connection.stats.stars || 0,
|
||||
icon: 'i-ph:star',
|
||||
iconColor: 'text-upage-elements-icon-warning',
|
||||
},
|
||||
{
|
||||
label: 'Forks',
|
||||
value: connection.stats.forks || 0,
|
||||
icon: 'i-ph:git-fork',
|
||||
iconColor: 'text-upage-elements-icon-info',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: connection.stats.followers || 0,
|
||||
icon: 'i-ph:users',
|
||||
iconColor: 'text-upage-elements-icon-success',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary flex items-center gap-1">
|
||||
<div className={`${stat.icon} size-4 ${stat.iconColor}`} />
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Gists</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Private',
|
||||
value: connection.stats.privateGists || 0,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-upage-elements-borderColor">
|
||||
<span className="text-xs text-upage-elements-textSecondary">
|
||||
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repositories Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary">Recent Repositories</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-4 rounded-lg bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive dark:hover:border-upage-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-mingcute:github-line size-4 text-upage-elements-icon-info dark:text-upage-elements-icon-info" />
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary group-hover:text-upage-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-upage-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<div className="i-ph:star w-3.5 h-3.5 text-upage-elements-icon-warning" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<div className="i-ph:git-fork w-3.5 h-3.5 text-upage-elements-icon-info" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-xs text-upage-elements-textSecondary line-clamp-2">{repo.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-upage-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<div className="i-ph:clock w-3.5 h-3.5" />
|
||||
{new Date(repo.updated_at).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 ml-auto group-hover:text-upage-elements-item-contentAccent transition-colors">
|
||||
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
|
||||
<span className="text-upage-elements-textSecondary">加载仓库中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
694
app/components/header/connections/NetlifyConnection.tsx
Normal file
694
app/components/header/connections/NetlifyConnection.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale/zh-CN';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '~/components/ui/Badge';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
|
||||
import { fetchNetlifyStats, isFetchingStats, netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify';
|
||||
import type { ConnectionSettings } from '~/root';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import type { NetlifyBuild, NetlifyDeploy, NetlifySite } from '~/types/netlify';
|
||||
import { logger } from '~/utils/logger';
|
||||
import ConnectionBorder from './components/ConnectionBorder';
|
||||
|
||||
// Add new interface for site actions
|
||||
interface SiteAction {
|
||||
name: string;
|
||||
icon: string;
|
||||
action: (siteId: string) => Promise<void>;
|
||||
requiresConfirmation?: boolean;
|
||||
variant?: 'default' | 'destructive' | 'outline';
|
||||
}
|
||||
|
||||
export default function NetlifyConnection() {
|
||||
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
|
||||
const connectFetcher = useFetcher<ApiResponse>();
|
||||
const settingsFetcher = useFetcher<ApiResponse>();
|
||||
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [sites, setSites] = useState<NetlifySite[]>([]);
|
||||
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
|
||||
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
|
||||
const [deploymentCount, setDeploymentCount] = useState(0);
|
||||
const [lastUpdated, setLastUpdated] = useState('');
|
||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
const isConnecting = useMemo(() => {
|
||||
return connectFetcher.state !== 'idle';
|
||||
}, [connectFetcher.state]);
|
||||
|
||||
useEffect(() => {
|
||||
updateNetlifyConnection({
|
||||
isConnect: rootData?.connectionSettings?.netlifyConnection,
|
||||
});
|
||||
}, [rootData]);
|
||||
|
||||
// Add site actions
|
||||
const siteActions: SiteAction[] = [
|
||||
{
|
||||
name: '清除缓存',
|
||||
icon: 'heroicons:arrow-path',
|
||||
action: async (siteId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/netlify/sites/${siteId}/cache`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '清除缓存失败');
|
||||
}
|
||||
|
||||
toast.success('站点缓存清除成功');
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : '未知错误';
|
||||
toast.error(`清除站点缓存失败: ${error}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '删除站点',
|
||||
icon: 'heroicons:trash',
|
||||
action: async (siteId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/netlify/sites/${siteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '删除站点失败');
|
||||
}
|
||||
|
||||
toast.success('站点删除成功');
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : '未知错误';
|
||||
toast.error(`删除站点失败: ${error}`);
|
||||
}
|
||||
},
|
||||
requiresConfirmation: true,
|
||||
variant: 'destructive',
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
|
||||
const response = await fetch(`/api/netlify/deploys/${deployId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ siteId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Failed to ${action} deploy`);
|
||||
}
|
||||
|
||||
toast.success(`Deploy ${action}ed successfully`);
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
toast.error(`Failed to ${action} deploy: ${error}`);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect && (!connection.stats || !connection.stats.sites)) {
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Update local state from connection
|
||||
if (connection.stats) {
|
||||
setSites(connection.stats.sites || []);
|
||||
setDeploys(connection.stats.deploys || []);
|
||||
setBuilds(connection.stats.builds || []);
|
||||
setDeploymentCount(connection.stats.deploys?.length || 0);
|
||||
setLastUpdated(connection.stats.lastDeployTime || '');
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// 监听 connectFetcher 状态变化(连接)
|
||||
useEffect(() => {
|
||||
if (connectFetcher.state === 'idle' && connectFetcher.data) {
|
||||
if (connectFetcher.data.success) {
|
||||
updateNetlifyConnection({
|
||||
isConnect: connectFetcher.data.data.isConnect,
|
||||
});
|
||||
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
});
|
||||
|
||||
toast.success('连接 Netlify 成功');
|
||||
setTokenInput('');
|
||||
} else if (connectFetcher.data.message) {
|
||||
toast.error(connectFetcher.data.message || '连接失败');
|
||||
}
|
||||
}
|
||||
}, [connectFetcher.state, connectFetcher.data]);
|
||||
|
||||
// 监听 settingsFetcher 状态变化(断开连接)
|
||||
useEffect(() => {
|
||||
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
|
||||
if (settingsFetcher.data.success) {
|
||||
localStorage.removeItem('netlify_connection');
|
||||
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
updateNetlifyConnection({ isConnect: false });
|
||||
toast.success('断开 Netlify 连接');
|
||||
}
|
||||
}
|
||||
}, [settingsFetcher.state, settingsFetcher.data]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!tokenInput) {
|
||||
toast.error('请输入 Netlify API 令牌');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connectFetcher.submit(
|
||||
{ token: tokenInput },
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/netlify/auth',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('连接 Netlify 失败:', error);
|
||||
toast.error(`连接 Netlify 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
settingsFetcher.submit(
|
||||
{
|
||||
category: 'connectivity',
|
||||
key: 'netlify_token',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
action: '/api/user/settings',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('断开 Netlify 连接失败');
|
||||
logger.error('断开 Netlify 连接失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStats = () => {
|
||||
if (!connection.isConnect || !connection.stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
Netlify 统计信息
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
|
||||
isStatsOpen ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>{connection.stats.totalSites} 站点</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:rocket-launch size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>{deploymentCount} 部署</span>
|
||||
</Badge>
|
||||
{lastUpdated && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:clock size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>更新于 {formatDistanceToNow(new Date(lastUpdated), { locale: zhCN })} 前</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{sites.length > 0 && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
您的站点
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
})
|
||||
}
|
||||
disabled={fetchingStats}
|
||||
className="flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive/10"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'heroicons:arrow-path size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent',
|
||||
{ 'animate-spin': fetchingStats },
|
||||
)}
|
||||
/>
|
||||
{fetchingStats ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sites.map((site, index) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className={classNames(
|
||||
'bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border rounded-lg p-4 transition-all',
|
||||
activeSiteIndex === index
|
||||
? 'border-upage-elements-item-contentAccent bg-upage-elements-item-backgroundActive/10'
|
||||
: 'border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveSiteIndex(index);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="heroicons:cloud size-5 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{site.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
{site.published_deploy?.state === 'ready' ? (
|
||||
<div className="heroicons:check-circle size-4 text-green-500" />
|
||||
) : (
|
||||
<div className="heroicons:x-circle size-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{site.published_deploy?.state || 'Unknown'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
href={site.ssl_url || site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="heroicons:cloud size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">
|
||||
{site.ssl_url || site.url}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{activeSiteIndex === index && (
|
||||
<>
|
||||
<div className="mt-4 pt-3 border-t border-upage-elements-borderColor">
|
||||
<div className="flex items-center gap-2">
|
||||
{siteActions.map((action) => (
|
||||
<Button
|
||||
key={action.name}
|
||||
variant={action.variant || 'outline'}
|
||||
size="sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (action.requiresConfirmation) {
|
||||
if (!confirm(`您确定要 ${action.name.toLowerCase()}?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsActionLoading(true);
|
||||
await action.action(site.id);
|
||||
setIsActionLoading(false);
|
||||
}}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div
|
||||
className={`${action.icon} size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent`}
|
||||
/>
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{site.published_deploy && (
|
||||
<div className="mt-3 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="heroicons:clock size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
发布于{' '}
|
||||
{formatDistanceToNow(new Date(site.published_deploy.published_at), {
|
||||
locale: zhCN,
|
||||
})}{' '}
|
||||
前
|
||||
</span>
|
||||
</div>
|
||||
{site.published_deploy.branch && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="heroicons:code-bracket size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
分支: {site.published_deploy.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeSiteIndex !== -1 && deploys.length > 0 && (
|
||||
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
最近部署
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deploys.map((deploy) => (
|
||||
<div
|
||||
key={deploy.id}
|
||||
className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
deploy.state === 'ready'
|
||||
? 'default'
|
||||
: deploy.state === 'error'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{deploy.state === 'ready' ? (
|
||||
<div className="heroicons:check-circle size-4 text-green-500" />
|
||||
) : deploy.state === 'error' ? (
|
||||
<div className="heroicons:x-circle size-4 text-red-500" />
|
||||
) : (
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{deploy.state}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
{formatDistanceToNow(new Date(deploy.created_at), { locale: zhCN })} 前
|
||||
</span>
|
||||
</div>
|
||||
{deploy.branch && (
|
||||
<div className="mt-2 text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary flex items-center gap-1">
|
||||
<div className="heroicons:code-bracket size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
分支: {deploy.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{deploy.deploy_url && (
|
||||
<div className="mt-2 text-xs">
|
||||
<a
|
||||
href={deploy.deploy_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="heroicons:cloud size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
发布
|
||||
</Button>
|
||||
{deploy.state === 'ready' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:lock-closed size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
锁定
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:lock-open size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
解锁
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSiteIndex !== -1 && builds.length > 0 && (
|
||||
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
<div className="heroicons:code-bracket size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
最近构建
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{builds.map((build) => (
|
||||
<div
|
||||
key={build.id}
|
||||
className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{build.done && !build.error ? (
|
||||
<div className="heroicons:check-circle size-4" />
|
||||
) : build.error ? (
|
||||
<div className="heroicons:x-circle size-4" />
|
||||
) : (
|
||||
<div className="heroicons:code-bracket size-4" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{build.done ? (build.error ? '失败' : '完成') : '进行中'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
{formatDistanceToNow(new Date(build.created_at), { locale: zhCN })} 前
|
||||
</span>
|
||||
</div>
|
||||
{build.error && (
|
||||
<div className="mt-2 text-xs text-upage-elements-textDestructive dark:text-upage-elements-textDestructive flex items-center gap-1">
|
||||
<div className="heroicons:x-circle size-3 text-upage-elements-textDestructive dark:text-upage-elements-textDestructive" />
|
||||
错误: {build.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
<div className="p-6">
|
||||
{!connection.isConnect ? (
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
|
||||
API 令牌
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
placeholder="输入您的 Netlify API 令牌"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1',
|
||||
'border border-upage-elements-borderColor dark:border-upage-elements-borderColor',
|
||||
'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary placeholder-upage-elements-textTertiary dark:placeholder-upage-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
<a
|
||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-upage-elements-link-text dark:text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:hover:text-upage-elements-link-textHover flex items-center gap-1"
|
||||
>
|
||||
<div className="i-ph:key size-4" />
|
||||
获取您的令牌
|
||||
<div className="i-ph:arrow-square-out size-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
variant="default"
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-upage-elements-button-secondary-background',
|
||||
'hover:bg-upage-elements-button-secondary-backgroundHover',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="heroicons:cloud size-4" />
|
||||
连接
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full gap-4 mt-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="heroicons:check-circle size-4 text-green-500" />
|
||||
<span className="text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
已连接到 Netlify
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://app.netlify.com', '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-mingcute:dashboard-line size-4" />
|
||||
仪表盘
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
})
|
||||
}
|
||||
disabled={fetchingStats}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
|
||||
>
|
||||
{fetchingStats ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap size-4 animate-spin text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
刷新...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="heroicons:arrow-path size-4 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
刷新统计
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
|
||||
<div className="i-ph:sign-out size-4" />
|
||||
断开连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{renderStats()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
314
app/components/header/connections/VercelConnection.tsx
Normal file
314
app/components/header/connections/VercelConnection.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { fetchVercelStats, isFetchingStats, updateVercelConnection, vercelConnection } from '~/lib/stores/vercel';
|
||||
import type { ConnectionSettings } from '~/root';
|
||||
import { logger } from '~/utils/logger';
|
||||
import ConnectionBorder from './components/ConnectionBorder';
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export default function VercelConnection() {
|
||||
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
|
||||
const settingsFetcher = useFetcher<ApiResponse>();
|
||||
const connectFetcher = useFetcher<ApiResponse>();
|
||||
|
||||
const connection = useStore(vercelConnection);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
updateVercelConnection({
|
||||
isConnect: rootData?.connectionSettings?.vercelConnection,
|
||||
});
|
||||
}, [rootData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
fetchVercelStats().catch((err) => {
|
||||
logger.error('获取 Vercel 统计信息失败:', err);
|
||||
});
|
||||
if (!connection.user) {
|
||||
handleConnect();
|
||||
}
|
||||
}
|
||||
}, [connection.isConnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
|
||||
if (settingsFetcher.data.success) {
|
||||
updateVercelConnection({ isConnect: false, user: null });
|
||||
toast.success('断开 Vercel 连接');
|
||||
}
|
||||
}
|
||||
}, [settingsFetcher.state, settingsFetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectFetcher.state === 'idle' && connectFetcher.data) {
|
||||
if (connectFetcher.data.success) {
|
||||
updateVercelConnection({
|
||||
isConnect: connectFetcher.data.data.isConnect,
|
||||
user: connectFetcher.data.data.user,
|
||||
});
|
||||
toast.success('连接 Vercel 成功');
|
||||
setTokenInput('');
|
||||
} else if (connectFetcher.data.message) {
|
||||
toast.error(connectFetcher.data.message || '连接失败');
|
||||
updateVercelConnection({ isConnect: false, user: null });
|
||||
}
|
||||
}
|
||||
}, [connectFetcher.state, connectFetcher.data]);
|
||||
|
||||
const isConnecting = useMemo(() => {
|
||||
return connectFetcher.state !== 'idle';
|
||||
}, [connectFetcher.state]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
try {
|
||||
connectFetcher.submit(
|
||||
{ token: tokenInput },
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/vercel/auth',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('连接 Vercel 失败');
|
||||
logger.error('连接 Vercel 失败:', error);
|
||||
logStore.logError('Failed to authenticate with Vercel', { error });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
settingsFetcher.submit(
|
||||
{
|
||||
category: 'connectivity',
|
||||
key: 'vercel_token',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
action: '/api/user/settings',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('断开 Vercel 连接失败');
|
||||
logger.error('断开 Vercel 连接失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
{!connection.isConnect ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary mb-2">个人访问令牌</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
disabled={isConnecting}
|
||||
placeholder="输入您的 Vercel 个人访问令牌"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-upage-elements-textSecondary">
|
||||
<a
|
||||
href="https://vercel.com/account/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-upage-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
获取您的令牌
|
||||
<div className="i-ph:arrow-square-out size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!tokenInput) {
|
||||
toast.error('请输入 Vercel 访问令牌');
|
||||
return;
|
||||
}
|
||||
handleConnect();
|
||||
}}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-upage-elements-button-secondary-background',
|
||||
'hover:bg-upage-elements-button-secondary-backgroundHover',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging size-4" />
|
||||
连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug size-4" />
|
||||
断开连接
|
||||
</button>
|
||||
<span className="text-sm text-upage-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle size-4 text-green-500" />
|
||||
已连接到 Vercel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
|
||||
|
||||
<img
|
||||
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
alt="User Avatar"
|
||||
className="size-12 rounded-full border-2 border-upage-elements-borderColorActive"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
|
||||
</h4>
|
||||
<p className="text-sm text-upage-elements-textSecondary">
|
||||
{connection.user?.email || connection.user?.user?.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fetchingStats ? (
|
||||
<div className="flex items-center gap-2 text-sm text-upage-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap size-4 animate-spin" />
|
||||
正在获取 Vercel 项目...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
|
||||
className="w-full bg-transparent text-left text-sm font-medium text-upage-elements-textPrimary mb-3 flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:buildings size-4" />
|
||||
您的项目 ({connection.stats?.totalProjects || 0})
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 ml-auto transition-transform',
|
||||
isProjectsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isProjectsExpanded && connection.stats?.projects?.length ? (
|
||||
<div className="grid gap-3">
|
||||
{connection.stats.projects.map((project) => (
|
||||
<a
|
||||
key={project.id}
|
||||
href={`https://vercel.com/dashboard/${project.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-4 rounded-lg border border-upage-elements-borderColor hover:border-upage-elements-borderColorActive transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:globe size-4 text-upage-elements-borderColorActive" />
|
||||
{project.name}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-upage-elements-textSecondary">
|
||||
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-upage-elements-borderColorActive"
|
||||
>
|
||||
{project.targets.production.alias.find(
|
||||
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
|
||||
) || project.targets.production.alias[0]}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock size-3" />
|
||||
{new Date(project.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.latestDeployments[0].url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-upage-elements-borderColorActive"
|
||||
>
|
||||
{project.latestDeployments[0].url}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock size-3" />
|
||||
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{project.framework && (
|
||||
<div className="text-xs text-upage-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-mingcute:code-line size-3" />
|
||||
{project.framework}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : isProjectsExpanded ? (
|
||||
<div className="text-sm text-upage-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info size-4" />
|
||||
未找到您的 Vercel 账户中的项目
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
581
app/components/header/connections/_1PanelConnection.tsx
Normal file
581
app/components/header/connections/_1PanelConnection.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale/zh-CN';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '~/components/ui/Badge';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
|
||||
import { _1PanelConnectionStore, fetch1PanelStats, isFetchingStats, update1PanelConnection } from '~/lib/stores/1panel';
|
||||
import { getChatId } from '~/lib/stores/ai-state';
|
||||
import type { ConnectionSettings } from '~/root';
|
||||
import type { _1PanelWebsite } from '~/types/1panel';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import ConnectionBorder from './components/ConnectionBorder';
|
||||
|
||||
export default function _1PanelConnection({
|
||||
isDeploying,
|
||||
onDeploy,
|
||||
}: {
|
||||
isDeploying: boolean;
|
||||
onDeploy: (siteId: number) => void;
|
||||
}) {
|
||||
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
|
||||
const connectFetcher = useFetcher<ApiResponse>();
|
||||
const settingsFetcher = useFetcher<ApiResponse>();
|
||||
|
||||
const connection = useStore(_1PanelConnectionStore);
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const fetching = useStore(isFetchingStats);
|
||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||
const [activeSiteIndex, setActiveSiteIndex] = useState(-1);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
// 使用 useMemo 计算 isConnecting 状态
|
||||
const isConnecting = useMemo(() => {
|
||||
return connectFetcher.state !== 'idle';
|
||||
}, [connectFetcher.state]);
|
||||
|
||||
useEffect(() => {
|
||||
update1PanelConnection({
|
||||
isConnect: rootData?.connectionSettings?._1PanelConnection,
|
||||
});
|
||||
}, [rootData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
fetch1PanelStats();
|
||||
}
|
||||
}, [connection.isConnect]);
|
||||
|
||||
// 监听 connectFetcher 状态变化(连接)
|
||||
useEffect(() => {
|
||||
const data = connectFetcher.data as ApiResponse<{
|
||||
websites: _1PanelWebsite[];
|
||||
totalWebsites: number;
|
||||
lastUpdated: string;
|
||||
}>;
|
||||
if (connectFetcher.state === 'idle' && data) {
|
||||
if (data.success) {
|
||||
update1PanelConnection({
|
||||
isConnect: true,
|
||||
stats: data.data,
|
||||
serverUrl,
|
||||
});
|
||||
toast.success('连接 1Panel 成功');
|
||||
} else if (data.message) {
|
||||
toast.error(`连接 1Panel 失败: ${data.message}`);
|
||||
}
|
||||
}
|
||||
}, [connectFetcher.state, connectFetcher.data, serverUrl]);
|
||||
|
||||
// 监听 settingsFetcher 状态变化(断开连接)
|
||||
useEffect(() => {
|
||||
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
|
||||
if (settingsFetcher.data.success) {
|
||||
update1PanelConnection({ isConnect: false, serverUrl: '' });
|
||||
toast.success('断开 1Panel 服务器连接');
|
||||
}
|
||||
}
|
||||
}, [settingsFetcher.state, settingsFetcher.data]);
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
if (!serverUrl) {
|
||||
toast.error('请填写服务器地址');
|
||||
return;
|
||||
}
|
||||
if (!apiKey) {
|
||||
toast.error('请输入 API 密钥');
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
connectFetcher.submit(
|
||||
{ serverUrl, apiKey },
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/1panel/auth',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`连接 1Panel 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
settingsFetcher.submit(
|
||||
{
|
||||
category: 'connectivity',
|
||||
key: '1panel_server_url',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
action: '/api/user/settings',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
await fetch('/api/user/settings', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
category: 'connectivity',
|
||||
key: '1panel_api_key',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('断开 1Panel 连接失败');
|
||||
console.error('断开 1Panel 连接失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWebsite = async (e: React.MouseEvent<HTMLButtonElement>, site: _1PanelWebsite) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!confirm(`您确定要删除站点 ${site.alias} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/1panel/websites', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siteId: site.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const { success, message } = (await response.json()) as ApiResponse;
|
||||
|
||||
if (!response.ok || !success) {
|
||||
toast.error(`删除站点失败: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(message || '站点删除成功');
|
||||
const currentSiteId = localStorage.getItem(`1panel-project-${getChatId()}`);
|
||||
if (currentSiteId === site.id.toString()) {
|
||||
localStorage.removeItem(`1panel-project-${getChatId()}`);
|
||||
}
|
||||
fetch1PanelStats();
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : '未知错误';
|
||||
toast.error(`删除站点失败: ${error}`);
|
||||
}
|
||||
setIsActionLoading(false);
|
||||
};
|
||||
|
||||
const handleDeployToSite = (e: React.MouseEvent<HTMLButtonElement>, site: _1PanelWebsite) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onDeploy(site.id);
|
||||
};
|
||||
|
||||
const formatExpirationDate = (date: string) => {
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return '未知';
|
||||
}
|
||||
// 将日期格式化为 YYYY-MM-DD
|
||||
const formattedDate = format(dateObj, 'yyyy-MM-dd');
|
||||
if (formattedDate === '9999-12-31') {
|
||||
return '永不过期';
|
||||
}
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
const renderStats = () => {
|
||||
if (!connection.isConnect || !connection.stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
1Panel 统计信息
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
|
||||
isStatsOpen ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>{connection.stats.totalWebsites} 站点</span>
|
||||
</Badge>
|
||||
{connection.stats.lastUpdated && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-lucide:clock size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>
|
||||
更新于 {formatDistanceToNow(new Date(connection.stats.lastUpdated), { locale: zhCN })} 前
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{connection.stats.websites.length > 0 && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
您的站点
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetch1PanelStats()}
|
||||
disabled={fetching}
|
||||
className="flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive/10"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'heroicons:arrow-path size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent',
|
||||
{ 'animate-spin': fetching },
|
||||
)}
|
||||
/>
|
||||
{fetching ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.websites.map((site, index) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className={classNames(
|
||||
'bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border rounded-lg p-4 transition-all',
|
||||
activeSiteIndex === index
|
||||
? 'border-upage-elements-item-contentAccent bg-upage-elements-item-backgroundActive/10'
|
||||
: 'border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (activeSiteIndex === index) {
|
||||
setActiveSiteIndex(-1);
|
||||
} else {
|
||||
setActiveSiteIndex(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="heroicons:globe-alt size-5 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{site.alias}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={site.status === 'Running' ? 'default' : 'destructive'}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
{site.status === 'Running' ? (
|
||||
<div className="i-lucide:check-circle size-4 text-green-500" />
|
||||
) : (
|
||||
<div className="i-lucide:x-circle size-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{site.status === 'Running' ? '已启动' : '已停止'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{site.domains.map((domain) => (
|
||||
<a
|
||||
key={domain.id}
|
||||
href={`${site.protocol.toLowerCase()}://${domain.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover w-fit"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="heroicons:paper-airplane size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">
|
||||
{`${site.protocol.toLowerCase()}://${domain.domain}`}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{(() => {
|
||||
const typeInfo = getWebsiteTypeInfo(site.type);
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded-md ${typeInfo.color}`}
|
||||
>
|
||||
<div className={`${typeInfo.icon} size-3`} />
|
||||
<span>{typeInfo.label}</span>
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
{activeSiteIndex === index && (
|
||||
<div className="flex gap-4 text-sm text-gray-700">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="i-lucide:clock size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
创建于{' '}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{formatDistanceToNow(new Date(site.createdAt), { locale: zhCN })}
|
||||
</span>{' '}
|
||||
前
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="i-pajamas:expire size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent"></div>
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
过期时间:{' '}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{formatExpirationDate(site.expireDate)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeSiteIndex === index && (
|
||||
<>
|
||||
<div className="mt-4 pt-3 border-t border-upage-elements-borderColor"></div>
|
||||
<div className="text-sm flex justify-end">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{site.type === 'static' && (
|
||||
<motion.button
|
||||
onClick={(e) => handleDeployToSite(e, site)}
|
||||
disabled={isDeploying}
|
||||
className="px-4 py-2 rounded-lg h-8 bg-black dark:bg-white dark:text-black text-white text-sm hover:bg-gray-800 dark:hover:bg-gray-200 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isDeploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
部署到此网站
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
<Button
|
||||
key="delete"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteWebsite(e, site)}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-lucide:trash size-4 text-white text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
|
||||
删除网站
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据网站类型返回对应的标签信息
|
||||
const getWebsiteTypeInfo = (type: string) => {
|
||||
switch (type) {
|
||||
case 'deployment':
|
||||
return {
|
||||
label: '一键部署',
|
||||
icon: 'i-ph:rocket-launch',
|
||||
color: 'bg-blue-100 text-blue-700 dark:bg-blue-800/30 dark:text-blue-300',
|
||||
};
|
||||
case 'runtime':
|
||||
return {
|
||||
label: '运行环境',
|
||||
icon: 'i-ph:code',
|
||||
color: 'bg-green-100 text-green-700 dark:bg-green-800/30 dark:text-green-300',
|
||||
};
|
||||
case 'proxy':
|
||||
return {
|
||||
label: '反向代理',
|
||||
icon: 'i-ph:arrows-left-right',
|
||||
color: 'bg-purple-100 text-purple-700 dark:bg-purple-800/30 dark:text-purple-300',
|
||||
};
|
||||
case 'static':
|
||||
return {
|
||||
label: '静态网站',
|
||||
icon: 'i-ph:file-html',
|
||||
color: 'bg-orange-100 text-orange-700 dark:bg-orange-800/30 dark:text-orange-300',
|
||||
};
|
||||
case 'subsite':
|
||||
return {
|
||||
label: '子网站',
|
||||
icon: 'i-ph:tree-structure',
|
||||
color: 'bg-gray-100 text-gray-700 dark:bg-gray-800/30 dark:text-gray-300',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: '未知类型',
|
||||
icon: 'i-ph:question',
|
||||
color: 'bg-gray-100 text-gray-700 dark:bg-gray-800/30 dark:text-gray-300',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
{!connection.isConnect ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary mb-2">服务器地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
disabled={fetching}
|
||||
placeholder="https://your-1panel-server.com"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary mb-2">API 密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={fetching}
|
||||
placeholder="请输入您的 API 密钥"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !serverUrl || !apiKey}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-upage-elements-button-secondary-background',
|
||||
'hover:bg-upage-elements-button-secondary-backgroundHover',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging size-4" />
|
||||
连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-upage-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle size-4 text-green-500" />
|
||||
已连接到 1Panel 服务器
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(`${connection.serverUrl}/websites`, '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-mingcute:dashboard-line size-4" />
|
||||
仪表盘
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => fetch1PanelStats()}
|
||||
disabled={fetching}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap size-4 animate-spin text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
刷新...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="heroicons:arrow-path size-4 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
刷新统计
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
|
||||
<div className="i-ph:sign-out size-4" />
|
||||
断开连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderStats()}
|
||||
</div>
|
||||
)}
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function ConnectionBorder({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className={classNames(
|
||||
'bg-upage-elements-background dark:bg-upage-elements-background border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6 space-y-6">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Suspense, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getLocalStorage } from '~/lib/persistence';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import type { GitHubUserResponse } from '~/types/github';
|
||||
import { formatSize } from '~/utils/format';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
const GitHubConnection = React.lazy(() => import('~/components/header/connections/GithubConnection'));
|
||||
|
||||
interface PushToGitHubDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
|
||||
}
|
||||
|
||||
interface GitHubRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
language: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
|
||||
const [repoName, setRepoName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
||||
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
||||
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
|
||||
const [isGitHubConnected, setIsGitHubConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
|
||||
// Load GitHub connection on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadGitHubConnection();
|
||||
}
|
||||
}, [isOpen, isGitHubConnected]);
|
||||
|
||||
const loadGitHubConnection = () => {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (connection?.user && connection?.token) {
|
||||
setUser(connection.user);
|
||||
setShowConnectionForm(false);
|
||||
|
||||
// Only fetch if we have both user and token
|
||||
if (connection.token.trim()) {
|
||||
fetchRecentRepos(connection.token);
|
||||
}
|
||||
} else {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加检测 GitHub 连接变化的 useEffect
|
||||
useEffect(() => {
|
||||
// 监听 localStorage 变化
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'github_connection' && e.newValue) {
|
||||
try {
|
||||
const connection = JSON.parse(e.newValue);
|
||||
if (connection?.user && connection?.token) {
|
||||
setIsGitHubConnected(true);
|
||||
loadGitHubConnection();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing github_connection from storage event:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 检查 localStorage 变化的函数,在内部组件触发
|
||||
const checkGitHubConnection = () => {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
if (connection?.user && connection?.token) {
|
||||
setIsGitHubConnected(true);
|
||||
setShowConnectionForm(false);
|
||||
loadGitHubConnection();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecentRepos = async (token: string) => {
|
||||
if (!token) {
|
||||
logStore.logError('No GitHub token available');
|
||||
toast.error('GitHub 认证失败');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingRepos(true);
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.github.com/user/repos?sort=updated&per_page=5&affiliation=owner,organization_member',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401) {
|
||||
toast.error('GitHub 令牌已过期。请重新连接您的账户。');
|
||||
|
||||
// Clear invalid token
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (connection) {
|
||||
localStorage.removeItem('github_connection');
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
logStore.logError('Failed to fetch GitHub repositories', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
});
|
||||
toast.error(`无法获取 GitHub 仓库: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const repos = (await response.json()) as GitHubRepo[];
|
||||
setRecentRepos(repos);
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to fetch GitHub repositories', { error });
|
||||
toast.error('无法获取最近仓库');
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (!connection?.token || !connection?.user) {
|
||||
setShowConnectionForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repoName.trim()) {
|
||||
toast.error('仓库名称是必需的');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Check if repository exists first
|
||||
const octokit = new Octokit({ auth: connection.token });
|
||||
|
||||
try {
|
||||
await octokit.repos.get({
|
||||
owner: connection.user.login,
|
||||
repo: repoName,
|
||||
});
|
||||
|
||||
// If we get here, the repo exists
|
||||
const confirmOverwrite = window.confirm(
|
||||
`仓库 "${repoName}" 已存在。是否要更新它?这将添加或修改仓库中的文件。`,
|
||||
);
|
||||
|
||||
if (!confirmOverwrite) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// 404 means repo doesn't exist, which is what we want for new repos
|
||||
if (error instanceof Error && 'status' in error && error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
|
||||
setCreatedRepoUrl(repoUrl);
|
||||
|
||||
// Get list of pushed files
|
||||
const files = await webBuilderStore.getProjectFilesAsMap({
|
||||
inline: false,
|
||||
});
|
||||
const filesList = Object.entries(files).map(([path, content]) => ({
|
||||
path,
|
||||
size: new TextEncoder().encode(content).length,
|
||||
}));
|
||||
|
||||
setPushedFiles(filesList);
|
||||
setShowSuccessDialog(true);
|
||||
} catch (error) {
|
||||
logger.error('Error pushing to GitHub:', error);
|
||||
toast.error('推送失败,请检查仓库名称并重试。');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRepoName('');
|
||||
setIsPrivate(false);
|
||||
setShowSuccessDialog(false);
|
||||
setCreatedRepoUrl('');
|
||||
setShowConnectionForm(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSwitchAccount = () => {
|
||||
setShowConnectionForm(true);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
// 清除 localStorage
|
||||
localStorage.removeItem('github_connection');
|
||||
// 清除 cookie
|
||||
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
// 更新状态
|
||||
setUser(null);
|
||||
setShowConnectionForm(true);
|
||||
toast.success('已断开与 GitHub 的连接');
|
||||
};
|
||||
|
||||
// Success Dialog
|
||||
if (showSuccessDialog) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[600px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<div className="i-ph:check-circle size-5" />
|
||||
<h3 className="text-lg font-medium">成功推送代码至 GitHub</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg p-3 text-left">
|
||||
<p className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark mb-2">
|
||||
仓库地址
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-upage-elements-background dark:bg-upage-elements-background-dark px-3 py-2 rounded border border-upage-elements-borderColor dark:border-upage-elements-borderColor-dark text-upage-elements-textPrimary dark:text-upage-elements-textPrimary-dark font-mono">
|
||||
{createdRepoUrl}
|
||||
</code>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(createdRepoUrl);
|
||||
toast.success('URL 已复制到剪贴板');
|
||||
}}
|
||||
className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div className="i-ph:copy size-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg p-3">
|
||||
<p className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark mb-2">
|
||||
推送的文件 ({pushedFiles.length})
|
||||
</p>
|
||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
{pushedFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center justify-between py-1 text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary-dark"
|
||||
>
|
||||
<span className="font-mono truncate flex-1">{file.path}</span>
|
||||
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark ml-2">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] sticky bottom-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<motion.a
|
||||
href={createdRepoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:github-logo size-4" />
|
||||
查看仓库
|
||||
</motion.a>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(createdRepoUrl);
|
||||
toast.success('URL 已复制到剪贴板');
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:copy size-4" />
|
||||
复制 URL
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
关闭
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (showConnectionForm) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo size-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{showConnectionForm ? 'GitHub 连接信息' : '连接 GitHub 账户'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{!showConnectionForm && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
需要连接 GitHub 账户才能将代码推送到 GitHub 仓库。请在此页面完成连接。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="github-connection-wrapper">
|
||||
<Suspense>
|
||||
<GitHubConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{isGitHubConnected || user ? (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
setShowConnectionForm(false);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrow-right" />
|
||||
推送列表
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
checkGitHubConnection();
|
||||
setTimeout(checkGitHubConnection, 500); // 延迟检查
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="size-10 rounded-xl bg-upage-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<div className="i-ph:git-branch size-5" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
推送到 GitHub
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">将代码推送到新的或现有的 GitHub 仓库</p>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="ml-auto flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
{user && (
|
||||
<div className="flex items-center justify-between gap-3 mb-6 p-3 bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={user?.avatar_url} alt={user?.login} className="size-10 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{user?.name || user?.login}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">@{user?.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.button
|
||||
onClick={handleSwitchAccount}
|
||||
className="px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-800 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary text-sm hover:bg-gray-300 dark:hover:bg-gray-700 inline-flex items-center gap-1"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:chart-bar size-4" />
|
||||
查看统计
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleDisconnect}
|
||||
className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600 inline-flex items-center gap-1"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:sign-out size-4" />
|
||||
断开连接
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form id="github-push-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
仓库名称
|
||||
</label>
|
||||
<input
|
||||
id="repoName"
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value)}
|
||||
placeholder="my-awesome-project"
|
||||
className="w-full px-4 py-2 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="private"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
|
||||
/>
|
||||
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
将仓库设置为私有
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{recentRepos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">最近仓库</label>
|
||||
<div className="space-y-2">
|
||||
{recentRepos.map((repo) => (
|
||||
<motion.button
|
||||
key={repo.full_name}
|
||||
type="button"
|
||||
onClick={() => setRepoName(repo.name)}
|
||||
className="w-full p-3 text-left rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 hover:bg-upage-elements-background-depth-3 dark:hover:bg-upage-elements-background-depth-4 transition-colors group"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-mingcute:github-line size-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
||||
{repo.name}
|
||||
</span>
|
||||
</div>
|
||||
{repo.private && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
{repo.language && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-mingcute:code-line size-3" />
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star size-3" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork size-3" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock size-3" />
|
||||
{new Date(repo.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetchingRepos && (
|
||||
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4 mr-2" />
|
||||
正在加载仓库...
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
取消
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="submit"
|
||||
form="github-push-form"
|
||||
disabled={isLoading}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
|
||||
isLoading ? 'opacity-50 cursor-not-allowed' : '',
|
||||
)}
|
||||
whileHover={!isLoading ? { scale: 1.02 } : {}}
|
||||
whileTap={!isLoading ? { scale: 0.98 } : {}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
|
||||
正在推送...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:git-branch size-4" />
|
||||
推送到 GitHub
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
178
app/components/sidebar/HistoryItem.tsx
Normal file
178
app/components/sidebar/HistoryItem.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useParams } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { type ForwardedRef, forwardRef, useCallback } from 'react';
|
||||
import { Checkbox } from '~/components/ui/Checkbox';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/lib/hooks';
|
||||
import type { ServerChatItem } from '~/lib/hooks/useChatEntries';
|
||||
|
||||
interface HistoryItemProps {
|
||||
item: ServerChatItem;
|
||||
onDelete?: (event: React.UIEvent) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
selectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({
|
||||
item,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
selectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
}: HistoryItemProps) {
|
||||
const { id } = useParams();
|
||||
const isActiveChat = id === item.id;
|
||||
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||
useEditChatDescription({
|
||||
initialDescription: item.description || '',
|
||||
chatId: item.id,
|
||||
});
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (selectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Item clicked in selection mode:', item.id);
|
||||
onToggleSelection?.(item.id);
|
||||
}
|
||||
},
|
||||
[selectionMode, item.id, onToggleSelection],
|
||||
);
|
||||
|
||||
const handleCheckboxChange = useCallback(() => {
|
||||
console.log('Checkbox changed for item:', item.id);
|
||||
onToggleSelection?.(item.id);
|
||||
}, [item.id, onToggleSelection]);
|
||||
|
||||
const handleDeleteClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log('Delete button clicked for item:', item.id);
|
||||
|
||||
if (onDelete) {
|
||||
onDelete(event as unknown as React.UIEvent);
|
||||
}
|
||||
},
|
||||
[onDelete, item.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'group rounded-lg text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50/80 dark:hover:bg-gray-800/30 overflow-hidden flex justify-between items-center px-1 py-2.5 transition-colors',
|
||||
{ 'text-gray-900 dark:text-white bg-gray-50/80 dark:bg-gray-800/30': isActiveChat },
|
||||
{ 'cursor-pointer': selectionMode },
|
||||
)}
|
||||
onClick={selectionMode ? handleItemClick : undefined}
|
||||
>
|
||||
<div
|
||||
className={classNames('flex items-center mr-2 invisible group-hover:visible', {
|
||||
'!visible': selectionMode,
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
id={`select-${item.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className="size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-white dark:bg-gray-900 text-gray-900 dark:text-white rounded-md px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-800 focus:outline-none focus:ring-1 focus:ring-purple-500/50"
|
||||
autoFocus
|
||||
value={currentDescription}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="i-ph:check size-4 text-gray-500 hover:text-purple-500 transition-colors"
|
||||
onMouseDown={handleSubmit}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<a
|
||||
href={`/chat/${item.id}`}
|
||||
className="flex w-full relative items-center"
|
||||
onClick={selectionMode ? handleItemClick : undefined}
|
||||
>
|
||||
<span className="truncate max-w-[calc(100%-90px)] pl-2">{currentDescription}</span>
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute right-0 top-0 bottom-0 flex items-center px-2 transition-colors',
|
||||
'min-w-[80px] justify-end z-10',
|
||||
'bg-gradient-to-l from-upage-elements-background-depth-1 via-upage-elements-background-depth-1 to-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onDuplicate && (
|
||||
<ChatActionButton
|
||||
toolTipContent="复制"
|
||||
icon="i-mingcute:copy-2-line size-4"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onDuplicate?.(item.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChatActionButton
|
||||
toolTipContent="重命名"
|
||||
icon="i-mingcute:edit-2-line size-4"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
<ChatActionButton
|
||||
toolTipContent="删除"
|
||||
icon="i-mingcute:delete-2-line size-4"
|
||||
className="hover:text-red-500 dark:hover:text-red-400"
|
||||
onClick={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChatActionButton = forwardRef(
|
||||
(
|
||||
{
|
||||
toolTipContent,
|
||||
icon,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
toolTipContent: string;
|
||||
icon: string;
|
||||
className?: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
btnTitle?: string;
|
||||
},
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
<WithTooltip tooltip={toolTipContent} position="bottom" sideOffset={4}>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={`text-gray-400 dark:text-gray-500 hover:text-purple-500 dark:hover:text-purple-400 transition-colors ${icon} ${className ? className : ''}`}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</WithTooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
23
app/components/sidebar/HistorySwitch.tsx
Normal file
23
app/components/sidebar/HistorySwitch.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { toggleSidebar } from '~/lib/stores/sidebar';
|
||||
|
||||
interface HistorySwitchProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const HistorySwitch = memo(({ className }: HistorySwitchProps) => {
|
||||
const [domLoaded, setDomLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDomLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
domLoaded && (
|
||||
<IconButton className={className} title="查看历史" onClick={toggleSidebar}>
|
||||
<div className="i-mingcute:history-line text-xl"></div>
|
||||
</IconButton>
|
||||
)
|
||||
);
|
||||
});
|
||||
381
app/components/sidebar/Menu.client.tsx
Normal file
381
app/components/sidebar/Menu.client.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ControlPanel } from '~/components/@settings/core/ControlPanel';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import { SettingsButton } from '~/components/ui/SettingsButton';
|
||||
import { useAuth } from '~/lib/hooks';
|
||||
import { type ServerChatItem, useChatEntries } from '~/lib/hooks/useChatEntries';
|
||||
import { useChatOperate } from '~/lib/hooks/useChatOperate';
|
||||
import { aiState } from '~/lib/stores/ai-state';
|
||||
import { sidebarStore } from '~/lib/stores/sidebar';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import WithTooltip from '../ui/Tooltip';
|
||||
import { binDates } from './date-binning';
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
|
||||
const menuVariants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
left: '-340px',
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
open: {
|
||||
opacity: 1,
|
||||
visibility: 'initial',
|
||||
left: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ServerChatItem } | { type: 'bulkDelete'; items: ServerChatItem[] } | null;
|
||||
|
||||
export const Menu = memo(() => {
|
||||
const { duplicateCurrentChat, deleteChat, deleteSelectedItems } = useChatOperate();
|
||||
const { entries, isLoading, loadChatEntries } = useChatEntries();
|
||||
const { chatId } = useStore(aiState);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const sidebar = useStore(sidebarStore);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { isAuthenticated } = useAuth();
|
||||
const isShowMenu = useMemo(() => {
|
||||
return isAuthenticated && sidebar;
|
||||
}, [isAuthenticated, sidebar]);
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
// 重新加载
|
||||
loadChatEntries(value);
|
||||
},
|
||||
[loadChatEntries],
|
||||
);
|
||||
|
||||
// 初始加载聊天列表,仅在组件挂载时执行一次
|
||||
useEffect(() => {
|
||||
if (isShowMenu && !isInitialized) {
|
||||
loadChatEntries();
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isShowMenu, loadChatEntries, isInitialized]);
|
||||
|
||||
const deleteItem = useCallback(
|
||||
async (event: React.UIEvent, item: ServerChatItem) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
console.log('Attempting to delete chat:', { id: item.id, description: item.description });
|
||||
|
||||
try {
|
||||
await deleteChat(item.id);
|
||||
toast.success('聊天已删除成功');
|
||||
if (chatId === item.id) {
|
||||
console.log('Navigating away from deleted chat');
|
||||
window.location.pathname = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chat:', error);
|
||||
toast.error('删除聊天失败');
|
||||
} finally {
|
||||
loadChatEntries();
|
||||
}
|
||||
},
|
||||
[loadChatEntries, deleteChat, chatId],
|
||||
);
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogContent(null);
|
||||
};
|
||||
|
||||
const toggleItemSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSelectedItems = prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id];
|
||||
console.log('Selected items updated:', newSelectedItems);
|
||||
|
||||
return newSelectedItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.length === 0) {
|
||||
toast.info('至少选择一个聊天来删除');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedChats = entries.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
if (selectedChats.length === 0) {
|
||||
toast.error('未找到选中的聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogContent({ type: 'bulkDelete', items: selectedChats });
|
||||
}, [selectedItems, entries]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
const allFilteredIds = entries.map((item) => item.id);
|
||||
setSelectedItems((prev) => {
|
||||
const allFilteredAreSelected = allFilteredIds.length > 0 && allFilteredIds.every((id) => prev.includes(id));
|
||||
|
||||
if (allFilteredAreSelected) {
|
||||
// Deselect only the filtered items
|
||||
const newSelectedItems = prev.filter((id) => !allFilteredIds.includes(id));
|
||||
console.log('Deselecting all filtered items. New selection:', newSelectedItems);
|
||||
|
||||
return newSelectedItems;
|
||||
}
|
||||
// Select all filtered items, adding them to any existing selections
|
||||
const newSelectedItems = [...new Set([...prev, ...allFilteredIds])];
|
||||
console.log('Selecting all filtered items. New selection:', newSelectedItems);
|
||||
|
||||
return newSelectedItems;
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
await duplicateCurrentChat(id);
|
||||
loadChatEntries();
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
setIsSettingsOpen(true);
|
||||
};
|
||||
|
||||
const handleSettingsClose = () => {
|
||||
setIsSettingsOpen(false);
|
||||
};
|
||||
|
||||
const setDialogContentWithLogging = useCallback((content: DialogContent) => {
|
||||
console.log('Setting dialog content:', content);
|
||||
setDialogContent(content);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSelectedItems = useCallback(
|
||||
async (itemsToDeleteNow: string[]) => {
|
||||
try {
|
||||
await deleteSelectedItems(itemsToDeleteNow);
|
||||
// 清空选择项
|
||||
setSelectedItems([]);
|
||||
|
||||
// 检查是否需要导航
|
||||
const currentChatId = chatId;
|
||||
if (currentChatId && itemsToDeleteNow.includes(currentChatId)) {
|
||||
console.log('Navigating away from deleted chat');
|
||||
window.location.pathname = '/';
|
||||
}
|
||||
|
||||
toast.success(`${itemsToDeleteNow.length} 个聊天已删除成功`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chats:', error);
|
||||
toast.error('删除聊天失败');
|
||||
} finally {
|
||||
loadChatEntries();
|
||||
}
|
||||
},
|
||||
[deleteSelectedItems, loadChatEntries, chatId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial="closed"
|
||||
animate={isShowMenu ? 'open' : 'closed'}
|
||||
variants={menuVariants}
|
||||
style={{ width: '300px' }}
|
||||
className={classNames(
|
||||
'flex selection-accent flex-col side-menu absolute h-full',
|
||||
'bg-upage-elements-background-depth-1 border-r border-gray-100 dark:border-gray-800/50',
|
||||
'shadow-sm text-sm',
|
||||
isSettingsOpen ? 'z-40' : 'z-sidebar',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 flex flex-col size-full overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
<a
|
||||
href="/"
|
||||
className="flex gap-2 items-center bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-500/20 rounded-lg px-4 py-2.5 transition-colors"
|
||||
>
|
||||
<span className="inline-block i-ph:plus-circle size-4" />
|
||||
<span className="text-sm font-medium">开始新的聊天</span>
|
||||
</a>
|
||||
<div className="relative w-full">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 z-1">
|
||||
<div
|
||||
className={`i-mingcute:search-2-line size-4 ${isLoading ? 'animate-pulse text-purple-500' : 'text-gray-400 dark:text-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="w-full bg-gray-50 dark:bg-gray-900 relative pl-9 pr-3 py-2 rounded-lg focus:outline-none focus:ring-1 focus:ring-purple-500/50 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-500 border border-gray-200 dark:border-gray-800"
|
||||
type="search"
|
||||
placeholder="搜索聊天记录..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
aria-label="搜索聊天记录"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-2 pb-20">
|
||||
{isLoading && entries.length === 0 ? (
|
||||
<div className="px-4 text-gray-500 dark:text-gray-400 text-sm">加载中...</div>
|
||||
) : (
|
||||
entries.length === 0 && (
|
||||
<div className="px-4 text-gray-500 dark:text-gray-400 text-sm">没有匹配的聊天记录</div>
|
||||
)
|
||||
)}
|
||||
<DialogRoot open={dialogContent !== null}>
|
||||
{binDates(entries).map(({ category, items }) => (
|
||||
<div key={category} className="mt-2 first:mt-0 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 sticky top-0 z-1 bg-upage-elements-background-depth-1 px-3 py-1">
|
||||
{category}
|
||||
</div>
|
||||
<div className="space-y-0.5 pr-1">
|
||||
{items.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDelete={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDialogContentWithLogging({ type: 'delete', item });
|
||||
}}
|
||||
onDuplicate={() => handleDuplicate(item.id)}
|
||||
selectionMode={selectedItems.length > 0}
|
||||
isSelected={selectedItems.includes(item.id)}
|
||||
onToggleSelection={toggleItemSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Dialog onBackdrop={closeDialog} onClose={closeDialog}>
|
||||
{dialogContent?.type === 'delete' && (
|
||||
<>
|
||||
<div className="p-6 bg-white dark:bg-gray-950">
|
||||
<DialogTitle className="text-gray-900 dark:text-white">删除聊天记录</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
你确定要删除{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{dialogContent.item.description}
|
||||
</span>{' '}
|
||||
聊天记录吗?
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800">
|
||||
<DialogButton type="secondary" onClick={closeDialog}>
|
||||
取消
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
type="danger"
|
||||
onClick={(event) => {
|
||||
deleteItem(event, dialogContent.item);
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</DialogButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dialogContent?.type === 'bulkDelete' && (
|
||||
<>
|
||||
<div className="p-6 bg-white dark:bg-gray-950">
|
||||
<DialogTitle className="text-gray-900 dark:text-white">删除选中的聊天</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
你确定要删除 {dialogContent.items.length} 聊天:
|
||||
<div className="mt-2 max-h-32 overflow-auto border border-gray-100 dark:border-gray-800 rounded-md bg-gray-50 dark:bg-gray-900 p-2">
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{dialogContent.items.map((item) => (
|
||||
<li key={item.id} className="text-sm">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{item.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<span className="mt-3 block">你确定要删除这些聊天记录吗?</span>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800">
|
||||
<DialogButton type="secondary" onClick={closeDialog}>
|
||||
取消
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
/*
|
||||
* Pass the current selectedItems to the delete function.
|
||||
* This captures the state at the moment the user confirms.
|
||||
*/
|
||||
const itemsToDeleteNow = [...selectedItems];
|
||||
handleDeleteSelectedItems(itemsToDeleteNow);
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</DialogButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
{selectedItems.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="absolute bottom-20 z-1 w-full flex justify-center"
|
||||
>
|
||||
<div className="rounded-full bg-upage-elements-background-depth-1 flex items-center justify-center border border-gray-200 dark:border-gray-800 shadow-md dark:shadow-gray-950/50 p-2.5 gap-3 transition-all duration-200">
|
||||
<WithTooltip tooltip={selectedItems.length === entries.length ? '取消全选' : '全选'}>
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="rounded-full bg-gray-50 dark:bg-gray-800 p-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors duration-200"
|
||||
>
|
||||
{selectedItems.length === entries.length ? (
|
||||
<div className="i-mingcute:checkbox-fill size-5 text-blue-500" />
|
||||
) : (
|
||||
<div className="i-mingcute:checkbox-line size-5 text-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
</WithTooltip>
|
||||
<WithTooltip tooltip="删除选中项">
|
||||
<button
|
||||
onClick={handleBulkDeleteClick}
|
||||
className="rounded-full bg-gray-50 dark:bg-gray-800 p-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors duration-200"
|
||||
>
|
||||
<div className="i-mingcute:delete-2-line size-5 text-red-500" />
|
||||
</button>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{import.meta.env.MODE === 'development' && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-800 px-4 py-3">
|
||||
<SettingsButton onClick={handleSettingsClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{import.meta.env.MODE === 'development' && <ControlPanel open={isSettingsOpen} onClose={handleSettingsClose} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
60
app/components/sidebar/date-binning.ts
Normal file
60
app/components/sidebar/date-binning.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale/zh-CN';
|
||||
import type { ServerChatItem } from '~/lib/hooks/useChatEntries';
|
||||
|
||||
type Bin = { category: string; items: ServerChatItem[] };
|
||||
|
||||
export function binDates(_list: ServerChatItem[]) {
|
||||
const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
const binLookup: Record<string, Bin> = {};
|
||||
const bins: Array<Bin> = [];
|
||||
|
||||
list.forEach((item) => {
|
||||
const category = dateCategory(new Date(item.timestamp));
|
||||
|
||||
if (!(category in binLookup)) {
|
||||
const bin = {
|
||||
category,
|
||||
items: [item],
|
||||
};
|
||||
|
||||
binLookup[category] = bin;
|
||||
|
||||
bins.push(bin);
|
||||
} else {
|
||||
binLookup[category].items.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return bins;
|
||||
}
|
||||
|
||||
function dateCategory(date: Date) {
|
||||
if (isToday(date)) {
|
||||
return '今天';
|
||||
}
|
||||
|
||||
if (isYesterday(date)) {
|
||||
return '昨天';
|
||||
}
|
||||
|
||||
if (isThisWeek(date)) {
|
||||
// e.g., "Mon" instead of "Monday"
|
||||
return format(date, 'EEE', { locale: zhCN });
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = subDays(new Date(), 30);
|
||||
|
||||
if (isAfter(date, thirtyDaysAgo)) {
|
||||
return '过去 30 天';
|
||||
}
|
||||
|
||||
if (isThisYear(date)) {
|
||||
// e.g., "Jan" instead of "January"
|
||||
return format(date, 'LLL', { locale: zhCN });
|
||||
}
|
||||
|
||||
// e.g., "Jan 2023" instead of "January 2023"
|
||||
return format(date, 'LLL yyyy', { locale: zhCN });
|
||||
}
|
||||
18
app/components/ui/BackgroundRays/index.tsx
Normal file
18
app/components/ui/BackgroundRays/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const BackgroundRays = () => {
|
||||
return (
|
||||
<div className={`${styles.rayContainer} `}>
|
||||
<div className={`${styles.lightRay} ${styles.ray1}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray2}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray3}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray4}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray5}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray6}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray7}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray8}`}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackgroundRays;
|
||||
246
app/components/ui/BackgroundRays/styles.module.scss
Normal file
246
app/components/ui/BackgroundRays/styles.module.scss
Normal file
@@ -0,0 +1,246 @@
|
||||
.rayContainer {
|
||||
// Theme-specific colors
|
||||
--ray-color-primary: color-mix(in srgb, var(--primary-color), transparent 30%);
|
||||
--ray-color-secondary: color-mix(in srgb, var(--secondary-color), transparent 30%);
|
||||
--ray-color-accent: color-mix(in srgb, var(--accent-color), transparent 30%);
|
||||
|
||||
// Theme-specific gradients
|
||||
--ray-gradient-primary: radial-gradient(var(--ray-color-primary) 0%, transparent 70%);
|
||||
--ray-gradient-secondary: radial-gradient(var(--ray-color-secondary) 0%, transparent 70%);
|
||||
--ray-gradient-accent: radial-gradient(var(--ray-color-accent) 0%, transparent 70%);
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 1.5s ease-out;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
// background-color: transparent;
|
||||
|
||||
:global(html[data-theme='dark']) & {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
:global(html[data-theme='light']) & {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
}
|
||||
|
||||
.lightRay {
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
|
||||
:global(html[data-theme='dark']) & {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
:global(html[data-theme='light']) & {
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.ray1 {
|
||||
width: 600px;
|
||||
height: 800px;
|
||||
background: var(--ray-gradient-primary);
|
||||
transform: rotate(65deg);
|
||||
top: -500px;
|
||||
left: -100px;
|
||||
filter: blur(80px);
|
||||
opacity: 0.6;
|
||||
animation: float1 15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray2 {
|
||||
width: 400px;
|
||||
height: 600px;
|
||||
background: var(--ray-gradient-secondary);
|
||||
transform: rotate(-30deg);
|
||||
top: -300px;
|
||||
left: 200px;
|
||||
filter: blur(60px);
|
||||
opacity: 0.6;
|
||||
animation: float2 18s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray3 {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
background: var(--ray-gradient-accent);
|
||||
top: -320px;
|
||||
left: 500px;
|
||||
filter: blur(65px);
|
||||
opacity: 0.5;
|
||||
animation: float3 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray4 {
|
||||
width: 400px;
|
||||
height: 450px;
|
||||
background: var(--ray-gradient-secondary);
|
||||
top: -350px;
|
||||
left: 800px;
|
||||
filter: blur(55px);
|
||||
opacity: 0.55;
|
||||
animation: float4 17s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray5 {
|
||||
width: 350px;
|
||||
height: 500px;
|
||||
background: var(--ray-gradient-primary);
|
||||
transform: rotate(-45deg);
|
||||
top: -250px;
|
||||
left: 1000px;
|
||||
filter: blur(45px);
|
||||
opacity: 0.6;
|
||||
animation: float5 16s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray6 {
|
||||
width: 300px;
|
||||
height: 700px;
|
||||
background: var(--ray-gradient-accent);
|
||||
transform: rotate(75deg);
|
||||
top: -400px;
|
||||
left: 600px;
|
||||
filter: blur(75px);
|
||||
opacity: 0.45;
|
||||
animation: float6 19s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray7 {
|
||||
width: 450px;
|
||||
height: 600px;
|
||||
background: var(--ray-gradient-primary);
|
||||
transform: rotate(45deg);
|
||||
top: -450px;
|
||||
left: 350px;
|
||||
filter: blur(65px);
|
||||
opacity: 0.55;
|
||||
animation: float7 21s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray8 {
|
||||
width: 380px;
|
||||
height: 550px;
|
||||
background: var(--ray-gradient-secondary);
|
||||
transform: rotate(-60deg);
|
||||
top: -380px;
|
||||
left: 750px;
|
||||
filter: blur(58px);
|
||||
opacity: 0.6;
|
||||
animation: float8 14s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes float1 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(65deg) translate(0, 0);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(70deg) translate(30px, 20px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(60deg) translate(-20px, 40px);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(68deg) translate(-40px, 10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-30deg) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: rotate(-25deg) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: rotate(-35deg) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(40px, 20px) rotate(5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-30px, 40px) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float4 {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15) rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float5 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-45deg) translate(0, 0);
|
||||
}
|
||||
33% {
|
||||
transform: rotate(-40deg) translate(25px, -20px);
|
||||
}
|
||||
66% {
|
||||
transform: rotate(-50deg) translate(-25px, 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float6 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(75deg) scale(1);
|
||||
filter: blur(75px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(85deg) scale(1.1);
|
||||
filter: blur(65px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float7 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(45deg) translate(0, 0);
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(40deg) translate(-30px, 30px);
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float8 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-60deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-55deg) scale(1.05);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-65deg) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
32
app/components/ui/Badge.tsx
Normal file
32
app/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-upage-elements-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-upage-elements-background text-upage-elements-textPrimary hover:bg-upage-elements-background/80',
|
||||
secondary:
|
||||
'border-transparent bg-upage-elements-background text-upage-elements-textSecondary hover:bg-upage-elements-background/80',
|
||||
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
|
||||
outline: 'text-upage-elements-textPrimary',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={classNames(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
47
app/components/ui/Button.tsx
Normal file
47
app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-upage-elements-borderColor disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-upage-elements-background text-upage-elements-textPrimary hover:bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 dark:text-upage-elements-textPrimary dark:hover:bg-upage-elements-background-depth-3',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
outline:
|
||||
'border border-upage-elements-borderColor bg-transparent hover:bg-upage-elements-background-depth-2 hover:text-upage-elements-textPrimary text-upage-elements-textPrimary dark:border-upage-elements-borderColorActive',
|
||||
secondary:
|
||||
'bg-upage-elements-background-depth-1 text-upage-elements-textPrimary hover:bg-upage-elements-background-depth-2',
|
||||
ghost: 'hover:bg-upage-elements-background-depth-1 hover:text-upage-elements-textPrimary',
|
||||
link: 'text-upage-elements-textPrimary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
_asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, _asChild = false, ...props }, ref) => {
|
||||
return <button className={classNames(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user