🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

40
.dockerignore Normal file
View 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
View 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
View 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"

View 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' }}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
```

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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 },
];

View 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;
};
}

View 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';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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,
};

View 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[];
};

View 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;
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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;
}
}
}

View 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>
);
});

View 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;
}
}

View 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>
}
</>
);
}

View 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>
);
}

View 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>
);
};

View File

@@ -0,0 +1,10 @@
.CopyButtonContainer {
button:before {
content: 'Copied';
font-size: 12px;
position: absolute;
left: -53px;
padding: 2px 6px;
height: 30px;
}
}

View 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>
);
},
);

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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;

View 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;
}
}
}

View 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);
});
});

View 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');
};

View 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>
);
},
);

View 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);
}

View 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>
);
};

View 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>
)
);
}

View 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>
);
};

View 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;
};

View 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>
);
};

View 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>
);
};

View 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;

View 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, '');
}

View 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>
)
);
}

View 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>
)
);
}

View 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>
);
};

View 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>
);
});

View 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>
);
}

View 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>
);
});

View 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>
);
};

View 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}
/>
);
},
);

View 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>
);
}

View 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();
}
}

View 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,
);
};

View 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>
);
});

View 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>
);
},
);

View 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>
);
};

View 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;
}

View 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>
);
};

View 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>
);
};

View 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>
);
});

View 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>
);
};

View 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';

View 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

View 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

View 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

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
</>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
},
);

View 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>
)
);
});

View 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} />}
</>
);
});

View 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 });
}

View 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;

View 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;
}
}

View 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 };

View 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