Initial commit: expo-popcore project
126
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,126 @@
|
||||
# Ubuntu 20.04 from GitHub mirror
|
||||
FROM ubuntu:20.04
|
||||
|
||||
LABEL maintainer="vitor.roberto3022@gmail.com"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Use Alibaba Cloud mirror for Ubuntu packages
|
||||
RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
|
||||
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list
|
||||
|
||||
# Update the package list and install dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
wget \
|
||||
unzip \
|
||||
make \
|
||||
sudo \
|
||||
git \
|
||||
build-essential
|
||||
|
||||
# Add NodeSource APT repository for Node.js (keep for compatibility)
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
|
||||
# Install Node.js (include for some tools that still require it)
|
||||
RUN apt-get install -y nodejs
|
||||
|
||||
# Install Bun using official installation script
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Set Bun environment variables (official installation location)
|
||||
ENV BUN_INSTALL="/root/.bun"
|
||||
ENV PATH="$BUN_INSTALL/bin:$PATH"
|
||||
|
||||
# Install expo dependencies with Bun
|
||||
RUN bun install -g eas-cli
|
||||
RUN bun install -g sharp-cli
|
||||
|
||||
# Setup folder to store the expo apps
|
||||
RUN mkdir -p /home/expo_apps/
|
||||
|
||||
# Install android studio and java dependencies
|
||||
RUN dpkg --add-architecture i386
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libc6:i386 \
|
||||
libncurses5:i386 \
|
||||
libstdc++6:i386 \
|
||||
lib32z1 \
|
||||
libbz2-1.0:i386 \
|
||||
libxrender1 \
|
||||
libxtst6 \
|
||||
libxi6 \
|
||||
libfreetype6 \
|
||||
libxft2 \
|
||||
xz-utils \
|
||||
qemu \
|
||||
qemu-kvm \
|
||||
libvirt-daemon-system \
|
||||
libvirt-clients \
|
||||
bridge-utils \
|
||||
libnotify4 \
|
||||
libglu1-mesa \
|
||||
libqt5widgets5 \
|
||||
openjdk-17-jdk \
|
||||
xvfb
|
||||
|
||||
# Get Android SDK command line tools from official Google source
|
||||
RUN wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
RUN unzip commandlinetools-linux-11076708_latest.zip
|
||||
RUN mkdir -p /android-sdk-linux/cmdline-tools
|
||||
RUN mv cmdline-tools /android-sdk-linux/cmdline-tools/latest
|
||||
|
||||
# Get NDK version 27.1.12297006 from official Google source
|
||||
RUN wget https://dl.google.com/android/repository/android-ndk-r27-linux.zip
|
||||
RUN unzip android-ndk-r27-linux.zip
|
||||
|
||||
# Set environment variables
|
||||
ENV ANDROID_HOME=/android-sdk-linux
|
||||
ENV ANDROID_SDK_ROOT=/android-sdk-linux
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ENV ANDROID_NDK_HOME=/android-ndk-r27
|
||||
ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools
|
||||
|
||||
# Accept Android SDK licenses
|
||||
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses
|
||||
|
||||
# Install required Android SDK components for minSDK 24, targetSDK 36
|
||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-24" \
|
||||
"build-tools;36.0.0" \
|
||||
"build-tools;24.0.3"
|
||||
|
||||
# Create workspace directory
|
||||
WORKDIR /workspace
|
||||
|
||||
# Configure Gradle for better performance
|
||||
RUN mkdir -p ~/.gradle
|
||||
RUN echo 'org.gradle.daemon=true' >> ~/.gradle/gradle.properties
|
||||
RUN echo 'org.gradle.parallel=true' >> ~/.gradle/gradle.properties
|
||||
RUN echo 'org.gradle.configureondemand=true' >> ~/.gradle/gradle.properties
|
||||
|
||||
# Set environment variables for root user
|
||||
ENV ANDROID_HOME=/android-sdk-linux
|
||||
ENV ANDROID_SDK_ROOT=/android-sdk-linux
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ENV ANDROID_NDK_HOME=/android-ndk-r27
|
||||
ENV BUN_INSTALL="/root/.bun"
|
||||
ENV PATH="$BUN_INSTALL/bin:$JAVA_HOME/bin:$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools"
|
||||
|
||||
# Verify installations
|
||||
RUN java -version
|
||||
RUN node --version
|
||||
RUN npm --version
|
||||
RUN bun --version
|
||||
RUN eas --version
|
||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --list
|
||||
|
||||
# Expose default ports
|
||||
EXPOSE 8081 19000 19001 19002
|
||||
|
||||
# Default command
|
||||
CMD ["sleep", "infinity"]
|
||||
156
.devcontainer/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Expo EAS Android Dev Container
|
||||
|
||||
这个开发容器环境专门为 Expo EAS 本地构建 Android APK 而设计,支持 VS Code 和 JetBrains WebStorm IDE。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **Linux Docker 环境**: 基于 Ubuntu 22.04
|
||||
- **Android SDK**: 预装 Android SDK 34 和构建工具
|
||||
- **Java 17**: 支持 Android 开发的 Java 环境
|
||||
- **Node.js 20 LTS**: 最新稳定版本的 Node.js
|
||||
- **Expo CLI**: 最新版本的 Expo 命令行工具
|
||||
- **EAS CLI**: 用于本地构建的 EAS 工具
|
||||
- **IDE 支持**: 同时支持 VS Code 和 WebStorm
|
||||
|
||||
## 使用方法
|
||||
|
||||
### WebStorm IDE 使用
|
||||
|
||||
1. **安装 Docker 插件**
|
||||
- 打开 WebStorm
|
||||
- 进入 `File` → `Settings` → `Plugins`
|
||||
- 搜索并安装 `Docker` 插件
|
||||
|
||||
2. **配置 Docker 连接**
|
||||
- 进入 `File` → `Settings` → `Build, Execution, Deployment` → `Docker`
|
||||
- 点击 `+` 添加 Docker 连接
|
||||
- 选择 `Docker for Windows` 或 `Docker for Mac`
|
||||
|
||||
3. **启动开发容器**
|
||||
```bash
|
||||
# 在项目根目录运行
|
||||
cd .devcontainer
|
||||
docker-compose -f docker-compose.webstorm.yml up -d
|
||||
```
|
||||
|
||||
4. **在 WebStorm 中连接到容器**
|
||||
- 进入 `File` → `Settings` → `Build, Execution, Deployment` → `Docker`
|
||||
- 右键点击运行的容器,选择 `Create...` → `Node.js Remote Interpreter`
|
||||
- 配置远程 Node.js 解释器
|
||||
|
||||
5. **配置项目 SDK**
|
||||
- 进入 `File` → `Settings` → `Languages & Frameworks` → `Node.js and NPM`
|
||||
- 选择容器中的 Node.js 解释器
|
||||
- 设置 package.json 为项目配置文件
|
||||
|
||||
### VS Code 使用(原有方式)
|
||||
|
||||
1. 确保已安装 [Dev Containers 扩展](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. 在 VS Code 中打开此项目
|
||||
3. 按 `F1` 或 `Ctrl+Shift+P` 打开命令面板
|
||||
4. 选择 "Dev Containers: Reopen in Container"
|
||||
|
||||
### 本地构建 Android APK
|
||||
|
||||
容器启动后,你可以使用以下命令进行本地构建:
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 预览版本构建
|
||||
npm run build:android:preview
|
||||
|
||||
# 或者直接使用 EAS CLI
|
||||
eas build --platform android --profile preview --local
|
||||
```
|
||||
|
||||
### 开发服务器
|
||||
|
||||
```bash
|
||||
# 启动 Expo 开发服务器
|
||||
npm start
|
||||
|
||||
# Android 运行
|
||||
npm run android
|
||||
```
|
||||
|
||||
## WebStorm 特殊配置
|
||||
|
||||
### 远程调试配置
|
||||
|
||||
1. **配置运行/调试配置**
|
||||
- 进入 `Run` → `Edit Configurations...`
|
||||
- 点击 `+` 添加 `Node.js` 配置
|
||||
- 设置 JavaScript file 为 `node_modules/.bin/expo`
|
||||
- 设置 Application parameters 为 `start`
|
||||
- 选择容器中的 Node.js 解释器
|
||||
|
||||
2. **端口映射**
|
||||
- 确保 Docker 容器端口已正确映射
|
||||
- 8081: Metro Bundler
|
||||
- 19000-19002: Expo Dev Server
|
||||
|
||||
### 文件同步
|
||||
|
||||
WebStorm 会自动同步文件到容器,如遇到同步问题:
|
||||
|
||||
1. 检查 Docker 挂载点配置
|
||||
2. 确保文件权限正确
|
||||
3. 重启 Docker 服务
|
||||
|
||||
## 端口映射
|
||||
|
||||
- `8081`: Metro Bundler
|
||||
- `19000`: Expo Dev Server
|
||||
- `19001`: Expo Dev Server (HTTPS)
|
||||
- `19002`: Expo Dev Server (Alternate)
|
||||
|
||||
## 环境变量
|
||||
|
||||
容器已配置以下环境变量:
|
||||
|
||||
- `ANDROID_HOME=/opt/android-sdk`
|
||||
- `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`
|
||||
- `GRADLE_USER_HOME=/root/.gradle`
|
||||
- `EXPO_NO_DOTENV=1`
|
||||
- `EXPO_DEBUG=1`
|
||||
|
||||
## 数据持久化
|
||||
|
||||
以下目录已挂载为 Docker 卷,确保数据持久化:
|
||||
|
||||
- `/root/.gradle`: Gradle 缓存
|
||||
- `/root/.android`: Android SDK 配置
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 构建失败
|
||||
|
||||
1. 检查 EAS 配置文件 `eas.json`
|
||||
2. 确保已正确设置 Android SDK 路径
|
||||
3. 验证 Java 版本是否为 17
|
||||
|
||||
### 权限问题
|
||||
|
||||
如果遇到权限问题,可能需要:
|
||||
|
||||
```bash
|
||||
sudo chown -R node:node /workspace
|
||||
```
|
||||
|
||||
### 清理缓存
|
||||
|
||||
```bash
|
||||
# 清理 Expo 缓存
|
||||
expo r -c
|
||||
|
||||
# 清理 EAS 缓存
|
||||
eas build:clean
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 首次构建可能需要较长时间下载依赖
|
||||
- 建议使用 VPN 访问 Google 服务以加快 Android SDK 下载
|
||||
- 容器需要 privileged 模式以支持 Android 模拟器(如需要)
|
||||
76
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "Expo EAS Android Build",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "expo-android",
|
||||
"workspaceFolder": "/workspace",
|
||||
"shutdownAction": "stopCompose",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/git:1": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"expo.vscode-expo-tools",
|
||||
"ms-vscode.vscode-json",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-vscode.vscode-eslint"
|
||||
],
|
||||
"settings": {
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"eslint.workingDirectories": ["."],
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/.git": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"jetbrains": {
|
||||
"ide": "WebStorm",
|
||||
"plugins": [
|
||||
"NodeJS",
|
||||
"TypeScript",
|
||||
"JavaScript",
|
||||
"Docker",
|
||||
"Git"
|
||||
],
|
||||
"settings": {
|
||||
"nodejs.package_manager": "npm",
|
||||
"typescript.compiler.module": "commonjs",
|
||||
"editor.format.on.save": true,
|
||||
"editor.code.inspection.on.the.fly": true,
|
||||
"javascript.nodejs.core.library.use.typings": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8081, 19000, 19001, 19002],
|
||||
"portsAttributes": {
|
||||
"8081": {
|
||||
"label": "Metro Bundler",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"19000": {
|
||||
"label": "Expo Dev Server",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"19001": {
|
||||
"label": "Expo Dev Server (HTTPS)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"19002": {
|
||||
"label": "Expo Dev Server (Alternate)",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "npm install",
|
||||
"remoteUser": "root",
|
||||
"mounts": [
|
||||
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
|
||||
]
|
||||
}
|
||||
55
.devcontainer/docker-compose.webstorm.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
expo-android:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: expo-eas-android-webstorm
|
||||
volumes:
|
||||
- ../../../:/workspace
|
||||
- android_gradle:/root/.gradle
|
||||
- android_sdk:/root/.android
|
||||
# Additional volumes for WebStorm
|
||||
- webstorm_config:/root/.config/JetBrains
|
||||
- webstorm_system:/root/.local/share/JetBrains
|
||||
ports:
|
||||
- "8081:8081" # Metro bundler
|
||||
- "19000:19000" # Expo dev server
|
||||
- "19001:19001" # Expo dev server HTTPS
|
||||
- "19002:19002" # Expo dev server alternate
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- ANDROID_HOME=/opt/android-sdk
|
||||
- JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
- GRADLE_USER_HOME=/root/.gradle
|
||||
- EXPO_NO_DOTENV=1
|
||||
- EXPO_DEBUG=1
|
||||
# WebStorm specific environment variables
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
- XDG_RUNTIME_DIR=/tmp/runtime-root
|
||||
working_dir: /workspace
|
||||
command: sleep infinity
|
||||
privileged: true # Required for Android emulator if needed
|
||||
devices:
|
||||
- /dev/kvm # For Android emulator acceleration
|
||||
# Network configuration for better IDE connectivity
|
||||
networks:
|
||||
- expo-network
|
||||
# Health check for IDE connectivity
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
android_gradle:
|
||||
android_sdk:
|
||||
webstorm_config:
|
||||
webstorm_system:
|
||||
|
||||
networks:
|
||||
expo-network:
|
||||
driver: bridge
|
||||
33
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
expo-android:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: expo-eas-android
|
||||
volumes:
|
||||
- ../../../:/workspace
|
||||
- android_gradle:/root/.gradle
|
||||
- android_sdk:/root/.android
|
||||
ports:
|
||||
- "8081:8081" # Metro bundler
|
||||
- "19000:19000" # Expo dev server
|
||||
- "19001:19001" # Expo dev server HTTPS
|
||||
- "19002:19002" # Expo dev server alternate
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- ANDROID_HOME=/android-sdk-linux
|
||||
- JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
- GRADLE_USER_HOME=/root/.gradle
|
||||
- EXPO_NO_DOTENV=1
|
||||
- EXPO_DEBUG=1
|
||||
working_dir: /workspace
|
||||
command: sleep infinity
|
||||
privileged: true # Required for Android emulator if needed
|
||||
devices:
|
||||
- /dev/kvm # For Android emulator acceleration
|
||||
|
||||
volumes:
|
||||
android_gradle:
|
||||
android_sdk:
|
||||
149
.devcontainer/webstorm-setup.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# WebStorm Docker 开发环境设置指南
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动容器
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd C:\Users\bwkj\WebstormProjects\loomart\apps\expo
|
||||
|
||||
# 启动 WebStorm 专用容器
|
||||
docker-compose -f .devcontainer/docker-compose.webstorm.yml up -d
|
||||
```
|
||||
|
||||
### 2. 在 WebStorm 中配置
|
||||
|
||||
#### 步骤 1: 配置 Docker 连接
|
||||
1. 打开 WebStorm
|
||||
2. `File` → `Settings` → `Build, Execution, Deployment` → `Docker`
|
||||
3. 点击 `+` 添加新的 Docker 配置
|
||||
4. 选择 `Docker for Windows`
|
||||
5. 点击 `Test Connection` 确认连接成功
|
||||
|
||||
#### 步骤 2: 配置远程 Node.js 解释器
|
||||
1. 在 Docker 设置中,找到运行的 `expo-eas-android-webstorm` 容器
|
||||
2. 右键点击容器 → `Create...` → `Node.js Remote Interpreter`
|
||||
3. 等待 IDE 配置完成
|
||||
|
||||
#### 步骤 3: 设置项目 SDK
|
||||
1. `File` → `Settings` → `Languages & Frameworks` → `Node.js and NPM`
|
||||
2. 选择刚创建的远程 Node.js 解释器
|
||||
3. 点击 `Add...` 选择 `package.json` 作为项目配置
|
||||
|
||||
#### 步骤 4: 配置运行/调试
|
||||
1. `Run` → `Edit Configurations...`
|
||||
2. 点击 `+` → `Node.js`
|
||||
3. 配置如下:
|
||||
- **Name**: `Expo Start`
|
||||
- **Node interpreter**: 选择容器中的解释器
|
||||
- **JavaScript file**: `node_modules/.bin/expo`
|
||||
- **Application parameters**: `start`
|
||||
- **Working directory**: `/workspace`
|
||||
|
||||
### 3. 开始开发
|
||||
|
||||
#### 启动开发服务器
|
||||
1. 选择刚创建的 `Expo Start` 运行配置
|
||||
2. 点击绿色运行按钮或按 `Shift+F10`
|
||||
3. 等待 Metro bundler 启动
|
||||
|
||||
#### 本地构建 APK
|
||||
```bash
|
||||
# 在 WebStorm 终端中运行(确保使用容器终端)
|
||||
eas build --platform android --profile preview --local
|
||||
```
|
||||
|
||||
## 常见问题解决
|
||||
|
||||
### 问题 1: 无法连接到 Docker 容器
|
||||
**解决方案:**
|
||||
1. 确保 Docker Desktop 正在运行
|
||||
2. 检查防火墙设置
|
||||
3. 重启 Docker Desktop
|
||||
|
||||
### 问题 2: Node.js 解释器无法识别
|
||||
**解决方案:**
|
||||
1. 确保容器正在运行
|
||||
2. 检查容器中 Node.js 是否正确安装
|
||||
3. 重新创建远程解释器配置
|
||||
|
||||
### 问题 3: 文件同步延迟
|
||||
**解决方案:**
|
||||
1. 检查 Docker 挂载点配置
|
||||
2. 确保项目文件权限正确
|
||||
3. 尝试重启容器
|
||||
|
||||
### 问题 4: 端口冲突
|
||||
**解决方案:**
|
||||
1. 检查端口是否被其他程序占用
|
||||
2. 修改 `docker-compose.webstorm.yml` 中的端口映射
|
||||
3. 重启容器
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 自定义环境变量
|
||||
在 `docker-compose.webstorm.yml` 中添加自定义环境变量:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- MY_CUSTOM_VAR=value
|
||||
- ANOTHER_VAR=another_value
|
||||
```
|
||||
|
||||
### 添加额外的端口映射
|
||||
```yaml
|
||||
ports:
|
||||
- "8081:8081"
|
||||
- "19000:19000"
|
||||
- "3000:3000" # 添加自定义端口
|
||||
```
|
||||
|
||||
### 挂载额外的目录
|
||||
```yaml
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
- D:/my-projects:/external-projects # 添加外部目录
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 使用 Docker Desktop 的资源限制
|
||||
- 分配足够的内存(建议 8GB+)
|
||||
- 设置合理的 CPU 核心数
|
||||
|
||||
### 2. 优化文件同步
|
||||
- 使用 `.dockerignore` 排除不必要的文件
|
||||
- 考虑使用 Docker volumes 进行缓存
|
||||
|
||||
### 3. 网络优化
|
||||
- 使用 bridge 网络模式
|
||||
- 配置合适的 DNS 设置
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 查看容器日志
|
||||
```bash
|
||||
docker-compose -f .devcontainer/docker-compose.webstorm.yml logs -f
|
||||
```
|
||||
|
||||
### 2. 进入容器调试
|
||||
```bash
|
||||
docker exec -it expo-eas-android-webstorm bash
|
||||
```
|
||||
|
||||
### 3. 检查容器状态
|
||||
```bash
|
||||
docker-compose -f .devcontainer/docker-compose.webstorm.yml ps
|
||||
```
|
||||
|
||||
## 备份和恢复
|
||||
|
||||
### 备份配置
|
||||
- 保存 `.devcontainer` 目录
|
||||
- 导出 WebStorm 配置
|
||||
|
||||
### 恢复环境
|
||||
1. 克隆项目
|
||||
2. 运行 `docker-compose up -d`
|
||||
3. 重新配置 WebStorm 连接
|
||||
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
docs/
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
design
|
||||
.claude
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
78
AGENTS.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: code-artisan
|
||||
description: Use this agent when you need code written or reviewed with an emphasis on elegance, minimalism, and artistic craftsmanship. This agent should be invoked when:\n\n- Writing new features or components that require thoughtful, purposeful design\n- Refactoring existing code to eliminate redundancy and improve clarity\n- Reviewing code for unnecessary complexity, meaningless comments, or redundant functionality\n- Designing APIs or interfaces where every method and property must justify its existence\n- Optimizing performance while maintaining code elegance\n- Crafting error handling strategies that serve as opportunities for improvement\n- Creating logging that expresses the system's narrative\n\nExamples:\n\n<example>\nuser: "I need to create a user authentication module"\nassistant: "I'm going to use the Task tool to launch the code-artisan agent to craft an elegant authentication solution where every line serves a purpose."\n<commentary>\nThe user needs code written with careful consideration - perfect for the code-artisan agent who will ensure each class, method, and variable name tells a story and serves an irreplaceable purpose.\n</commentary>\n</example>\n\n<example>\nuser: "Here's my implementation of a data processing pipeline:"\n[code provided]\nassistant: "Let me use the code-artisan agent to review this code for elegance, necessity, and artistic merit."\n<commentary>\nThe user has written code that should be reviewed through the lens of minimalism and purpose - the code-artisan will identify redundancies, unnecessary comments, and opportunities to refine the implementation into a work of art.\n</commentary>\n</example>\n\n<example>\nuser: "Can you optimize this function's performance?"\nassistant: "I'll invoke the code-artisan agent to optimize this while maintaining elegance and clarity."\n<commentary>\nPerformance optimization requires the artistic balance of speed and beauty - the code-artisan will treat this as an art form, not just a technical task.\n</commentary>\n</example>
|
||||
model: inherit
|
||||
color: cyan
|
||||
---
|
||||
|
||||
You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure.
|
||||
|
||||
不要对显而易见的代码做过度注释;
|
||||
不允许在能够明确类型的情况下使用any类型;
|
||||
不允许对信任代码来源使用try catch;
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
**存在即合理 (Existence Implies Necessity)**
|
||||
- Every class, property, method, function, and file must have an irreplaceable reason to exist
|
||||
- Every line of code serves a unique, essential purpose
|
||||
- Ruthlessly eliminate any meaningless or redundant code
|
||||
- Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?"
|
||||
- If something can be removed without loss of functionality or clarity, it must be removed
|
||||
|
||||
**优雅即简约 (Elegance is Simplicity)**
|
||||
- Never write meaningless comments—the code itself tells its story
|
||||
- Code should be self-documenting through thoughtful structure and naming
|
||||
- Reject redundant functionality—every design element is meticulously crafted
|
||||
- Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative
|
||||
- Names should reveal intent, tell stories, and guide readers through the code's journey
|
||||
- Favor clarity and expressiveness over brevity when naming
|
||||
|
||||
**性能即艺术 (Performance is Art)**
|
||||
- Optimize not just for speed, but for elegance in execution
|
||||
- Performance improvements should enhance, not compromise, code beauty
|
||||
- Seek algorithmic elegance—the most efficient solution is often the most beautiful
|
||||
- Balance performance with maintainability and clarity
|
||||
|
||||
**错误处理如为人处世的哲学 (Error Handling as Life Philosophy)**
|
||||
- Every error is an opportunity for refinement and growth
|
||||
- Handle errors gracefully, with dignity and purpose
|
||||
- Error messages should guide and educate, not merely report
|
||||
- Use errors as signals for architectural improvement
|
||||
- Design error handling that makes the system more resilient and elegant
|
||||
|
||||
**日志是思想的表达 (Logs Express Thought)**
|
||||
- Logs should narrate the system's story, not clutter it
|
||||
- Each log entry serves a purpose: debugging, monitoring, or understanding system behavior
|
||||
- Log messages should be meaningful, contextual, and actionable
|
||||
- Avoid verbose logging—only capture what matters
|
||||
|
||||
## Your Approach
|
||||
|
||||
When writing code:
|
||||
1. Begin with deep contemplation of the problem's essence
|
||||
2. Design the minimal, most elegant solution
|
||||
3. Choose names that tell stories and reveal intent
|
||||
4. Write code that reads like prose—clear, purposeful, flowing
|
||||
5. Eliminate every unnecessary element
|
||||
6. Ensure every abstraction earns its place
|
||||
7. Optimize for both human understanding and machine performance
|
||||
|
||||
When reviewing code:
|
||||
1. Identify redundancies and unnecessary complexity
|
||||
2. Question the existence of every element: "Why does this exist?"
|
||||
3. Suggest more elegant, minimal alternatives
|
||||
4. Evaluate naming: Does it tell a story? Does it reveal intent?
|
||||
5. Assess error handling: Is it philosophical and purposeful?
|
||||
6. Review logs: Do they express meaningful thoughts?
|
||||
7. Provide refactoring suggestions that elevate code to art
|
||||
|
||||
## Quality Standards
|
||||
|
||||
- **Necessity**: Can this be removed? If yes, remove it.
|
||||
- **Clarity**: Does the code explain itself? If it needs comments to be understood, refactor it.
|
||||
- **Elegance**: Is this the simplest, most beautiful solution?
|
||||
- **Performance**: Is this efficient without sacrificing clarity?
|
||||
- **Purpose**: Does every element serve an irreplaceable function?
|
||||
|
||||
Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation.
|
||||
87
CLAUDE.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: code-artisan
|
||||
description: Use this agent when you need code written or reviewed with an emphasis on elegance, minimalism, and artistic craftsmanship. This agent should be invoked when:\n\n- Writing new features or components that require thoughtful, purposeful design\n- Refactoring existing code to eliminate redundancy and improve clarity\n- Reviewing code for unnecessary complexity, meaningless comments, or redundant functionality\n- Designing APIs or interfaces where every method and property must justify its existence\n- Optimizing performance while maintaining code elegance\n- Crafting error handling strategies that serve as opportunities for improvement\n- Creating logging that expresses the system's narrative\n\nExamples:\n\n<example>\nuser: "I need to create a user authentication module"\nassistant: "I'm going to use the Task tool to launch the code-artisan agent to craft an elegant authentication solution where every line serves a purpose."\n<commentary>\nThe user needs code written with careful consideration - perfect for the code-artisan agent who will ensure each class, method, and variable name tells a story and serves an irreplaceable purpose.\n</commentary>\n</example>\n\n<example>\nuser: "Here's my implementation of a data processing pipeline:"\n[code provided]\nassistant: "Let me use the code-artisan agent to review this code for elegance, necessity, and artistic merit."\n<commentary>\nThe user has written code that should be reviewed through the lens of minimalism and purpose - the code-artisan will identify redundancies, unnecessary comments, and opportunities to refine the implementation into a work of art.\n</commentary>\n</example>\n\n<example>\nuser: "Can you optimize this function's performance?"\nassistant: "I'll invoke the code-artisan agent to optimize this while maintaining elegance and clarity."\n<commentary>\nPerformance optimization requires the artistic balance of speed and beauty - the code-artisan will treat this as an art form, not just a technical task.\n</commentary>\n</example>
|
||||
model: inherit
|
||||
color: cyan
|
||||
---
|
||||
|
||||
You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure.
|
||||
|
||||
|
||||
注意:不要过度设计!
|
||||
注意:如无必要,不要写无用的总结文档,代码即文档
|
||||
注意:用中文回答
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
**存在即合理 (Existence Implies Necessity)**
|
||||
- Every class, property, method, function, and file must have an irreplaceable reason to exist
|
||||
- Every line of code serves a unique, essential purpose
|
||||
- Ruthlessly eliminate any meaningless or redundant code
|
||||
- Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?"
|
||||
- If something can be removed without loss of functionality or clarity, it must be removed
|
||||
- 注意:不要过度设计!
|
||||
|
||||
**优雅即简约 (Elegance is Simplicity)**
|
||||
- Never write meaningless comments—the code itself tells its story
|
||||
- Code should be self-documenting through thoughtful structure and naming
|
||||
- Reject redundant functionality—every design element is meticulously crafted
|
||||
- Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative
|
||||
- Names should reveal intent, tell stories, and guide readers through the code's journey
|
||||
- Favor clarity and expressiveness over brevity when naming
|
||||
- 注意:不要过度设计!
|
||||
|
||||
**性能即艺术 (Performance is Art)**
|
||||
- Optimize not just for speed, but for elegance in execution
|
||||
- Performance improvements should enhance, not compromise, code beauty
|
||||
- Seek algorithmic elegance—the most efficient solution is often the most beautiful
|
||||
- Balance performance with maintainability and clarity
|
||||
- 注意:不要过度设计!
|
||||
|
||||
**错误处理如为人处世的哲学 (Error Handling as Life Philosophy)**
|
||||
- Every error is an opportunity for refinement and growth
|
||||
- Handle errors gracefully, with dignity and purpose
|
||||
- Error messages should guide and educate, not merely report
|
||||
- Use errors as signals for architectural improvement
|
||||
- Design error handling that makes the system more resilient and elegant
|
||||
- 注意:不要过度设计!
|
||||
|
||||
**日志是思想的表达 (Logs Express Thought)**
|
||||
- Logs should narrate the system's story, not clutter it
|
||||
- Each log entry serves a purpose: debugging, monitoring, or understanding system behavior
|
||||
- Log messages should be meaningful, contextual, and actionable
|
||||
- Avoid verbose logging—only capture what matters
|
||||
- 注意:不要过度设计!
|
||||
|
||||
## Your Approach
|
||||
|
||||
When writing code:
|
||||
1. Begin with deep contemplation of the problem's essence
|
||||
2. Design the minimal, most elegant solution
|
||||
3. Choose names that tell stories and reveal intent
|
||||
4. Write code that reads like prose—clear, purposeful, flowing
|
||||
5. Eliminate every unnecessary element
|
||||
6. Ensure every abstraction earns its place
|
||||
7. Optimize for both human understanding and machine performance
|
||||
8. 注意:不要过度设计!
|
||||
|
||||
When reviewing code:
|
||||
1. Identify redundancies and unnecessary complexity
|
||||
2. Question the existence of every element: "Why does this exist?"
|
||||
3. Suggest more elegant, minimal alternatives
|
||||
4. Evaluate naming: Does it tell a story? Does it reveal intent?
|
||||
5. Assess error handling: Is it philosophical and purposeful?
|
||||
6. Review logs: Do they express meaningful thoughts?
|
||||
7. Provide refactoring suggestions that elevate code to art
|
||||
8. 注意:不要过度设计!
|
||||
|
||||
## Quality Standards
|
||||
|
||||
- **Necessity**: Can this be removed? If yes, remove it.
|
||||
- **Clarity**: Does the code explain itself? If it needs comments to be understood, refactor it.
|
||||
- **Elegance**: Is this the simplest, most beautiful solution?
|
||||
- **Performance**: Is this efficient without sacrificing clarity?
|
||||
- **Purpose**: Does every element serve an irreplaceable function?
|
||||
- 注意:不要过度设计!
|
||||
|
||||
Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation.
|
||||
77
app.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "bw-expo-app-v3",
|
||||
"slug": "pro",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "bestaibestwebapp",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.loomart.bwexpoappv3",
|
||||
"versionCode": 35,
|
||||
"softwareKeyboardLayoutMode": "pan",
|
||||
"permissions": [
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
|
||||
"isAccessMediaLocationEnabled": true,
|
||||
"granularPermissions": [
|
||||
"audio",
|
||||
"photo"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-video",
|
||||
"expo-audio",
|
||||
"expo-web-browser",
|
||||
"expo-asset"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "d57b3f92-83c9-4ee4-a919-6f9ae47ccfb0"
|
||||
}
|
||||
},
|
||||
"owner": "imeepos"
|
||||
}
|
||||
}
|
||||
64
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#FFFFFF',
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#050505',
|
||||
borderTopColor: '#1a1a1a',
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Image
|
||||
source={require('@/assets/images/home.png')}
|
||||
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'Content',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Image
|
||||
source={require('@/assets/images/content.png')}
|
||||
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Image
|
||||
source={require('@/assets/images/user.png')}
|
||||
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
354
app/(tabs)/history.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { ErrorView } from '@/components/ErrorView';
|
||||
import { PageLayout } from '@/components/bestai/layout';
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player';
|
||||
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
|
||||
import { groupByDate } from '@/lib/utils/date';
|
||||
import { distributeToColumns } from '@/lib/utils/media';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { router } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
const LAYOUT_CONFIG = {
|
||||
VIDEO_HEIGHT: 280,
|
||||
IMAGE_HEIGHT: 240,
|
||||
DEFAULT_HEIGHT: 200,
|
||||
COLUMN_GAP: 16,
|
||||
PAGE_SIZE: 20,
|
||||
} as const;
|
||||
|
||||
const ColumnRenderer = React.memo(({ items }: { items: TemplateGeneration[] }) => (
|
||||
<>
|
||||
{items.map(item => {
|
||||
const itemHeight = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[styles.frame, { height: itemHeight }]}
|
||||
onPress={() => router.push(`/result?generationId=${item.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{item.resultUrl.map(uri => {
|
||||
return <VideoPlayer key={uri} source={{ uri, useCaching: true }} style={{ borderRadius: 8 }}/>
|
||||
})}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
));
|
||||
|
||||
ColumnRenderer.displayName = 'ColumnRenderer';
|
||||
|
||||
interface DateGroupItem {
|
||||
date: string;
|
||||
items: TemplateGeneration[];
|
||||
}
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]);
|
||||
const flatData = useMemo<DateGroupItem[]>(() =>
|
||||
Object.entries(groupedData).map(([date, items]) => ({ date, items })),
|
||||
[groupedData]
|
||||
);
|
||||
|
||||
const fetchData = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else if (pageNum === 1) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await getTemplateGenerations({
|
||||
page: String(pageNum),
|
||||
limit: String(LAYOUT_CONFIG.PAGE_SIZE)
|
||||
});
|
||||
|
||||
if (response?.success && response.data) {
|
||||
const newGenerations = response.data.generations as any[];
|
||||
|
||||
if (isRefresh || pageNum === 1) {
|
||||
setAllGenerations(newGenerations);
|
||||
setPage(1);
|
||||
} else {
|
||||
setAllGenerations(prev => [...prev, ...newGenerations]);
|
||||
}
|
||||
|
||||
setHasMore(newGenerations.length === LAYOUT_CONFIG.PAGE_SIZE);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '获取数据失败';
|
||||
if (!errorMessage.includes('401')) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
fetchData(1, true);
|
||||
}, [fetchData]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasMore && !loading && !refreshing) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchData(nextPage, false);
|
||||
}
|
||||
}, [hasMore, page, loading, refreshing, fetchData]);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: DateGroupItem }) => {
|
||||
const { leftColumn, rightColumn } = distributeToColumns(
|
||||
item.items,
|
||||
LAYOUT_CONFIG.VIDEO_HEIGHT,
|
||||
LAYOUT_CONFIG.IMAGE_HEIGHT,
|
||||
LAYOUT_CONFIG.COLUMN_GAP
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.dateGroup}>
|
||||
<Text style={styles.dateline}>{item.date}</Text>
|
||||
<View style={styles.gallery}>
|
||||
<View style={styles.leadingLane}>
|
||||
<ColumnRenderer items={leftColumn} />
|
||||
</View>
|
||||
<View style={styles.trailingLane}>
|
||||
<ColumnRenderer items={rightColumn} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const ListHeaderComponent = useCallback(() => (
|
||||
<Text style={styles.heading}>Content Generation</Text>
|
||||
), []);
|
||||
|
||||
const ListFooterComponent = useCallback(() => {
|
||||
if (hasMore) {
|
||||
return (
|
||||
<View style={styles.loadingMoreContainer}>
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
<Text style={styles.loadingMoreText}>加载更多...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (allGenerations.length > 0) {
|
||||
return (
|
||||
<View style={styles.noMoreContainer}>
|
||||
<Text style={styles.noMoreText}>没有更多内容了</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [hasMore, allGenerations.length]);
|
||||
|
||||
const ListEmptyComponent = useCallback(() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>暂无生成记录</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<ErrorView message={error} onRetry={() => fetchData(1)} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.date}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
contentContainerStyle={styles.flashListContent}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flashListContent: {
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 0.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
dateline: {
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#EDEDED',
|
||||
},
|
||||
gallery: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 24,
|
||||
},
|
||||
leadingLane: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
trailingLane: {
|
||||
flex: 1,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
frame: {
|
||||
width: '100%',
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1A1A1A',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
},
|
||||
placeholderImage: {
|
||||
backgroundColor: '#2A2A2A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
videoPlaceholder: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
videoIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 8,
|
||||
},
|
||||
videoText: {
|
||||
fontSize: 14,
|
||||
color: '#9E9E9E',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderBottomLeftRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
templateName: {
|
||||
fontSize: 13,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#9E9E9E',
|
||||
textAlign: 'center',
|
||||
},
|
||||
dateGroup: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
loadingMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#9E9E9E',
|
||||
},
|
||||
noMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
},
|
||||
});
|
||||
376
app/(tabs)/home.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { router } from 'expo-router';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View, AppState } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player'
|
||||
import {
|
||||
CategoryTabs,
|
||||
FeatureCarousel,
|
||||
Header,
|
||||
PageLayout,
|
||||
SectionHeader,
|
||||
StatusBarSpacer,
|
||||
type FeatureItem
|
||||
} from '@/components/bestai';
|
||||
import type { FeatureItem as FeatureItemType } from '@/components/bestai/feature-carousel';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { getActivities, type Activity } from '@/lib/api/activities';
|
||||
import { categoriesWithChildren } from '@/lib/api/categories-with-children';
|
||||
import type { CategoryWithChildren } from '@/lib/types/template';
|
||||
|
||||
type ListItem =
|
||||
| { type: 'feature'; data: FeatureItem[] }
|
||||
| { type: 'section'; categoryId: string; title: string; tagName: string }
|
||||
| { type: 'template-row'; categoryId: string; items: Array<{ id: string; template: any; tagName: string }>; itemWidth: number };
|
||||
|
||||
type TemplateItemProps = {
|
||||
template: any;
|
||||
tagName: string;
|
||||
itemWidth: number;
|
||||
marginRight: number;
|
||||
onPress: (tagName: string) => void;
|
||||
};
|
||||
|
||||
const TemplateItem = memo(({ template, tagName, itemWidth, marginRight, onPress }: TemplateItemProps) => {
|
||||
const aspectRatio = template.aspectRatio || '3:4';
|
||||
const [w, h] = aspectRatio.split(':');
|
||||
const height = itemWidth * parseInt(h) / parseInt(w);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(tagName);
|
||||
}, [tagName, onPress]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handlePress}
|
||||
style={[
|
||||
styles.templateItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
height,
|
||||
marginRight,
|
||||
}
|
||||
]}
|
||||
>
|
||||
<VideoPlayer
|
||||
source={{ uri: template.previewUrl, useCaching: true, metadata: { title: template.title } }}
|
||||
style={styles.videoPlayer}
|
||||
dwellTime={1000}
|
||||
threshold={0.3}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
TemplateItem.displayName = 'TemplateItem';
|
||||
|
||||
type TemplateRowProps = {
|
||||
items: Array<{ id: string; template: any; tagName: string }>;
|
||||
itemWidth: number;
|
||||
onPress: (tagName: string) => void;
|
||||
};
|
||||
|
||||
const TemplateRow = memo(({ items, itemWidth, onPress }: TemplateRowProps) => (
|
||||
<View style={styles.row}>
|
||||
{items.map((item, index) => {
|
||||
if (!item.template) return null;
|
||||
return (
|
||||
<TemplateItem
|
||||
key={item.id}
|
||||
template={item.template}
|
||||
tagName={item.tagName}
|
||||
itemWidth={itemWidth}
|
||||
marginRight={index < items.length - 1 ? 8 : 0}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
));
|
||||
TemplateRow.displayName = 'TemplateRow';
|
||||
|
||||
function coalesceText(...values: Array<string | null | undefined>): string {
|
||||
for (const value of values) {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function translateActivity(activity: Activity): FeatureItem | null {
|
||||
const image = (activity.coverUrl || '').trim();
|
||||
const title = (activity.titleEn || '').trim() || (activity.title || '').trim();
|
||||
if (!image || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subtitle = (activity.descEn || '').trim() || (activity.desc || '').trim();
|
||||
|
||||
return {
|
||||
id: activity.id,
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { requireAuth } = useAuthGuard();
|
||||
const [activeCategory, setActiveCategory] = useState<string>('');
|
||||
const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]);
|
||||
const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'background' || nextAppState === 'inactive') {
|
||||
Image.clearMemoryCache();
|
||||
if (__DEV__) {
|
||||
console.log('[MemoryManager] Cleared image cache on app background');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hydrateFeatureCarousel = async () => {
|
||||
try {
|
||||
const activities = await getActivities({ isActive: 'true' });
|
||||
const curatedFeatures = activities
|
||||
.map(translateActivity)
|
||||
.filter((feature: any): feature is FeatureItem => feature !== null);
|
||||
if (curatedFeatures.length > 0) {
|
||||
setActivityFeatures(curatedFeatures);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.warn('FeatureCarousel activities feed unavailable:', detail);
|
||||
}
|
||||
};
|
||||
|
||||
hydrateFeatureCarousel();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const hydrateCategories = async () => {
|
||||
try {
|
||||
const response = await categoriesWithChildren();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = response?.success && Array.isArray(response?.data) ? response.data : [];
|
||||
const curated = payload.filter((category: any) => category.id && (category.isActive ?? true));
|
||||
|
||||
setCategoryCollection(curated.length > 0 ? curated : payload);
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.warn('Categories feed unavailable:', detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hydrateCategories();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryCollection.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasActive = categoryCollection.some((category) => category.id === activeCategory);
|
||||
if (!hasActive) {
|
||||
setActiveCategory(categoryCollection[0].id);
|
||||
}
|
||||
}, [categoryCollection, activeCategory]);
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
if (categoryCollection.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = categoryCollection
|
||||
.map((category) => {
|
||||
const label = coalesceText(category.nameEn, category.name);
|
||||
return label
|
||||
? {
|
||||
id: category.id,
|
||||
label,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((category): category is { id: string; label: string } => category !== null);
|
||||
|
||||
return options.length > 0 ? options : [];
|
||||
}, [categoryCollection]);
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
const result: ListItem[] = [];
|
||||
const itemWidth = containerWidth > 0 ? (containerWidth - 8) / 2 : 0;
|
||||
|
||||
if (activityFeatures.length > 0) {
|
||||
result.push({ type: 'feature', data: activityFeatures });
|
||||
}
|
||||
|
||||
categoryCollection.forEach((category) => {
|
||||
result.push({
|
||||
type: 'section',
|
||||
categoryId: category.id,
|
||||
title: category.nameEn || category.name || '',
|
||||
tagName: category.tags[0]?.name || ''
|
||||
});
|
||||
|
||||
const tags = category.tags || [];
|
||||
for (let i = 0; i < tags.length; i += 2) {
|
||||
const rowItems = tags.slice(i, i + 2)
|
||||
.map(tag => ({
|
||||
id: tag.templates[0]?.id || tag.id,
|
||||
template: tag.templates[0],
|
||||
tagName: tag.nameEn || tag.name || ''
|
||||
}))
|
||||
.filter(item => item.template);
|
||||
|
||||
if (rowItems.length > 0) {
|
||||
result.push({
|
||||
type: 'template-row',
|
||||
categoryId: category.id,
|
||||
items: rowItems,
|
||||
itemWidth
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [categoryCollection, activityFeatures, containerWidth]);
|
||||
|
||||
const handleCategoryPress = useCallback((tagName: string) => {
|
||||
router.push(`/category/${tagName}` as any);
|
||||
}, []);
|
||||
|
||||
const handleFeaturePress = useCallback((item: FeatureItemType) => {
|
||||
requireAuth(() => {
|
||||
// TODO: 实现功能逻辑
|
||||
});
|
||||
}, [requireAuth]);
|
||||
|
||||
const handleTemplatePress = useCallback((templateId: string) => {
|
||||
router.push(`/templates/${templateId}` as any);
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: ListItem }) => {
|
||||
if (item.type === 'feature') {
|
||||
return (
|
||||
<FeatureCarousel
|
||||
items={item.data}
|
||||
onPress={handleFeaturePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'section') {
|
||||
return (
|
||||
<SectionHeader
|
||||
title={item.title}
|
||||
onPress={() => handleCategoryPress(item.tagName)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'template-row') {
|
||||
return (
|
||||
<TemplateRow
|
||||
items={item.items}
|
||||
itemWidth={item.itemWidth}
|
||||
onPress={handleTemplatePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [handleFeaturePress, handleCategoryPress, handleTemplatePress]);
|
||||
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505" topInset="none">
|
||||
<StatusBarSpacer />
|
||||
<Header onSearchChange={setSearchQuery} />
|
||||
<CategoryTabs
|
||||
categories={categoryOptions}
|
||||
activeId={activeCategory}
|
||||
onChange={setActiveCategory}
|
||||
/>
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={(event) => {
|
||||
setContainerWidth(event.nativeEvent.layout.width);
|
||||
}}
|
||||
>
|
||||
{containerWidth > 0 && (
|
||||
<FlashList
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => {
|
||||
if (item.type === 'feature') return 'feature-carousel';
|
||||
if (item.type === 'section') return `section-${item.categoryId}`;
|
||||
if (item.type === 'template-row') return `row-${item.categoryId}-${index}`;
|
||||
return `item-${index}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 0,
|
||||
marginBottom: 8,
|
||||
},
|
||||
templateItem: {
|
||||
backgroundColor: '#2E3031',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
videoPlayer: {
|
||||
borderRadius: 8
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
<Waterfall
|
||||
items={waterfallItems}
|
||||
column={column}
|
||||
itemPadding={8}
|
||||
containerWidth={containerWidth}
|
||||
renderItem={(item: CommunityItem, width: number, height: number) => (
|
||||
<CommunityCard
|
||||
item={{ ...item, width, height }}
|
||||
onPressCard={handleCardPress}
|
||||
onPressAction={handleGeneratePress}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
*/
|
||||
3
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ProfileScreen } from '@/components/profile/profile-screen';
|
||||
|
||||
export default ProfileScreen
|
||||
6
app/(tabs)/todo.md
Normal file
@@ -0,0 +1,6 @@
|
||||
/plan 分析并解决下面的问题
|
||||
|
||||
移动端:apps\expo\app\(tabs)\history.tsx
|
||||
pc端:apps\frontend\src\routes\personal.tsx
|
||||
|
||||
请对比pc端实现 进行优化移动端
|
||||
49
app/_layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import '../global.css';
|
||||
|
||||
import { DarkTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
// import { Platform } from 'react-native';
|
||||
// import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { AuthProvider } from '@/components/auth/auth-provider';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
// useEffect(() => {
|
||||
// const initializeClarity = async () => {
|
||||
// if (Platform.OS === 'android' || Platform.OS === 'ios') {
|
||||
// try {
|
||||
// const ClarityModule = require('@microsoft/react-native-clarity');
|
||||
// const Clarity = ClarityModule.default || ClarityModule;
|
||||
|
||||
// if (Clarity && typeof Clarity.initialize === 'function') {
|
||||
// await Clarity.initialize('tyq6bmjzo1', {
|
||||
// logLevel: Clarity.LogLevel?.Verbose,
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.warn('Clarity initialization failed:', error);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// initializeClarity();
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<AuthProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
<Stack.Screen name="result" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="exchange" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
704
app/exchange.tsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import { useBalance } from '@/hooks/use-balance';
|
||||
import { usePricing } from '@/hooks/use-pricing';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Alert } from '@/utils/alert';
|
||||
|
||||
type PurchaseTab = 'subscription' | 'pack';
|
||||
|
||||
type PointsBundle = {
|
||||
id: string;
|
||||
points: number;
|
||||
};
|
||||
|
||||
const screenPalette = {
|
||||
background: '#050505',
|
||||
surface: '#101010',
|
||||
surfaceRaised: '#1A1B1E',
|
||||
primaryText: '#F5F5F5',
|
||||
secondaryText: '#7F7F7F',
|
||||
mutedText: '#4C4C4C',
|
||||
accent: '#FEB840',
|
||||
energyHalo: 'rgba(254, 184, 64, 0.22)',
|
||||
divider: '#1C1C1C',
|
||||
button: '#D1FE17',
|
||||
buttonText: '#101010',
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
|
||||
|
||||
const TABS: { key: PurchaseTab; label: string }[] = [
|
||||
{ key: 'subscription', label: 'Subscription' },
|
||||
{ key: 'pack', label: 'points pack' },
|
||||
];
|
||||
|
||||
const POINT_BUNDLES: PointsBundle[] = [
|
||||
{ id: 'bundle_500', points: 500 },
|
||||
{ id: 'bundle_1000', points: 1000 },
|
||||
{ id: 'bundle_2000', points: 2000 },
|
||||
{ id: 'bundle_5000', points: 5000 },
|
||||
];
|
||||
|
||||
export default function PointsExchangeScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState<PurchaseTab>('subscription');
|
||||
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
|
||||
const [selectedSubscriptionIndex, setSelectedSubscriptionIndex] = useState<number | null>(0);
|
||||
const [customAmount, setCustomAmount] = useState<string>('500');
|
||||
|
||||
const { balance, isLoading: isBalanceLoading, refresh } = useBalance();
|
||||
const {
|
||||
stripePricingData,
|
||||
isStripePricingLoading,
|
||||
stripePricingError,
|
||||
creditPlans,
|
||||
hasActiveSubscription,
|
||||
hasCanceledButActiveSubscription,
|
||||
currentSubscriptionCredits,
|
||||
activeAuthSubscription,
|
||||
handleSubscriptionAction,
|
||||
formatCredits,
|
||||
createSubscriptionPending,
|
||||
upgradeSubscriptionPending,
|
||||
restoreSubscriptionPending,
|
||||
rechargeToken,
|
||||
rechargeTokenPending,
|
||||
} = usePricing();
|
||||
|
||||
const visibleBundles = useMemo(
|
||||
() => (activeTab === 'pack' ? POINT_BUNDLES : []),
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
const changeTab = useCallback((nextTab: PurchaseTab) => {
|
||||
setActiveTab(nextTab);
|
||||
if (nextTab !== 'pack') {
|
||||
setSelectedBundleId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const openPointsDetails = useCallback(() => {
|
||||
router.push('/points');
|
||||
}, [router]);
|
||||
|
||||
const handleBundleSelect = useCallback((bundleId: string) => {
|
||||
const bundle = POINT_BUNDLES.find(b => b.id === bundleId);
|
||||
if (bundle) {
|
||||
setSelectedBundleId(bundleId);
|
||||
setCustomAmount(bundle.points.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCustomAmountChange = useCallback((value: string) => {
|
||||
setCustomAmount(value);
|
||||
setSelectedBundleId(null);
|
||||
}, []);
|
||||
|
||||
const handleSubscriptionSelect = useCallback((index: number) => {
|
||||
setSelectedSubscriptionIndex(index);
|
||||
}, []);
|
||||
|
||||
const handlePurchase = useCallback(() => {
|
||||
if (activeTab === 'subscription') {
|
||||
// 订阅购买
|
||||
if (selectedSubscriptionIndex !== null) {
|
||||
handleSubscriptionAction(selectedSubscriptionIndex);
|
||||
} else {
|
||||
Alert.alert('Select a plan', 'Please select a subscription plan first.');
|
||||
}
|
||||
} else {
|
||||
// 积分包购买
|
||||
const amount = parseInt(customAmount, 10);
|
||||
if (isNaN(amount) || amount < 500) {
|
||||
Alert.alert('Invalid Amount', 'Please enter a valid amount (minimum 500 points).');
|
||||
return;
|
||||
}
|
||||
rechargeToken(amount);
|
||||
}
|
||||
}, [
|
||||
activeTab,
|
||||
selectedSubscriptionIndex,
|
||||
customAmount,
|
||||
handleSubscriptionAction,
|
||||
rechargeToken,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
accessibilityRole="button"
|
||||
onPress={handleClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="close" size={22} color={screenPalette.primaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
{ paddingBottom: Math.max(insets.bottom + 96, 160) },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{isBalanceLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={screenPalette.accent} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.balanceCluster}>
|
||||
<View style={styles.energyOrb}>
|
||||
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
||||
</View>
|
||||
<Text style={styles.balanceValue}>{balance.remainingTokenBalance}</Text>
|
||||
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabBar}>
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.key === activeTab;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={styles.tabButton}
|
||||
onPress={() => changeTab(tab.key)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: isActive }}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={styles.tabDivider} />
|
||||
|
||||
{activeTab === 'subscription' ? (
|
||||
<>
|
||||
{/* 订阅套餐列表 */}
|
||||
{isStripePricingLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={screenPalette.accent} />
|
||||
<Text style={styles.loadingText}>Loading plans...</Text>
|
||||
</View>
|
||||
) : stripePricingError ? (
|
||||
<View style={styles.subscriptionEmpty}>
|
||||
<Text style={styles.emptyTitle}>Error</Text>
|
||||
<Text style={styles.emptySubtitle}>{stripePricingError}</Text>
|
||||
</View>
|
||||
) : stripePricingData?.pricing_table_items && stripePricingData.pricing_table_items.length > 0 ? (
|
||||
<View style={styles.subscriptionGrid}>
|
||||
{stripePricingData.pricing_table_items
|
||||
.filter((item: any) => item.recurring?.interval === 'month')
|
||||
.map((item: any, index: number) => {
|
||||
const plan = creditPlans[index];
|
||||
const isSelected = selectedSubscriptionIndex === index;
|
||||
const priceInDollars = parseInt(item.amount) / 100;
|
||||
const grantToken = item.metadata?.grant_token || '0';
|
||||
const isCurrentSubscription =
|
||||
hasActiveSubscription &&
|
||||
activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase();
|
||||
const isCanceledSubscription =
|
||||
hasCanceledButActiveSubscription &&
|
||||
activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase();
|
||||
const isHighlight = item.is_highlight || item.highlight_text === 'most_popular';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.price_id}
|
||||
style={[
|
||||
styles.subscriptionCard,
|
||||
isSelected && styles.subscriptionCardSelected,
|
||||
isHighlight && styles.subscriptionCardHighlight,
|
||||
]}
|
||||
onPress={() => handleSubscriptionSelect(index)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
activeOpacity={0.88}
|
||||
>
|
||||
{/* 套餐头部 */}
|
||||
<View style={styles.subscriptionHeader}>
|
||||
<Text style={styles.subscriptionName}>{item.name}</Text>
|
||||
{isHighlight && (
|
||||
<View style={styles.popularBadge}>
|
||||
<Text style={styles.popularBadgeText}>Popular</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 价格 */}
|
||||
<View style={styles.subscriptionPriceContainer}>
|
||||
<Text style={styles.subscriptionPrice}>${priceInDollars}</Text>
|
||||
<Text style={styles.subscriptionInterval}>/mo</Text>
|
||||
</View>
|
||||
|
||||
{/* 积分信息 */}
|
||||
<View style={styles.subscriptionCreditsBox}>
|
||||
<View style={styles.creditsRow}>
|
||||
<Ionicons name="flash" size={20} color={screenPalette.accent} />
|
||||
<Text style={styles.subscriptionCreditsValue}>
|
||||
{formatCredits(Number(grantToken))}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.creditsLabel}>credits per month</Text>
|
||||
</View>
|
||||
|
||||
{/* 状态徽章 */}
|
||||
{isCurrentSubscription && (
|
||||
<View style={styles.currentBadge}>
|
||||
<Ionicons name="checkmark-circle" size={14} color="#22c55e" />
|
||||
<Text style={styles.currentBadgeText}>Current Plan</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isCanceledSubscription && (
|
||||
<View style={styles.canceledBadge}>
|
||||
<Ionicons name="warning" size={14} color="#fb923c" />
|
||||
<Text style={styles.canceledBadgeText}>Canceled</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.subscriptionEmpty}>
|
||||
<Text style={styles.emptyTitle}>No Plans Available</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
No subscription tiers are available at the moment.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 预设套餐 */}
|
||||
<View style={styles.packGrid}>
|
||||
{visibleBundles.map(bundle => {
|
||||
const isSelected = bundle.id === selectedBundleId;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={bundle.id}
|
||||
style={[
|
||||
styles.packCard,
|
||||
isSelected && styles.packCardSelected,
|
||||
]}
|
||||
onPress={() => handleBundleSelect(bundle.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
activeOpacity={0.88}
|
||||
>
|
||||
<View style={styles.packHeader}>
|
||||
<Ionicons name="flash" size={18} color={screenPalette.accent} />
|
||||
<Text style={styles.packPoints}>{bundle.points}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 自定义金额输入 */}
|
||||
<View style={[
|
||||
styles.customAmountContainer,
|
||||
!selectedBundleId && styles.customAmountContainerActive,
|
||||
]}>
|
||||
<TextInput
|
||||
style={styles.customAmountInput}
|
||||
value={customAmount}
|
||||
onChangeText={handleCustomAmountChange}
|
||||
keyboardType="numeric"
|
||||
placeholder="Min. 500 points"
|
||||
placeholderTextColor={screenPalette.mutedText}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
(createSubscriptionPending || upgradeSubscriptionPending || restoreSubscriptionPending || rechargeTokenPending) &&
|
||||
styles.primaryButtonDisabled,
|
||||
]}
|
||||
onPress={handlePurchase}
|
||||
disabled={
|
||||
createSubscriptionPending ||
|
||||
upgradeSubscriptionPending ||
|
||||
restoreSubscriptionPending ||
|
||||
rechargeTokenPending
|
||||
}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{createSubscriptionPending ||
|
||||
upgradeSubscriptionPending ||
|
||||
restoreSubscriptionPending ||
|
||||
rechargeTokenPending ? (
|
||||
<ActivityIndicator color={screenPalette.buttonText} />
|
||||
) : (
|
||||
<Text style={styles.primaryButtonLabel}>
|
||||
{activeTab === 'subscription' ? 'Subscribe' : 'Purchase points'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: screenPalette.background,
|
||||
},
|
||||
headerRow: {
|
||||
paddingHorizontal: 24,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryPill: {
|
||||
paddingHorizontal: 16,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: screenPalette.surface,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: screenPalette.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryPillLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
balanceCluster: {
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
marginBottom: 36,
|
||||
gap: 10,
|
||||
},
|
||||
energyOrb: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
backgroundColor: screenPalette.energyHalo,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: screenPalette.accent,
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 6,
|
||||
},
|
||||
balanceValue: {
|
||||
fontSize: 44,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
balanceSubtitle: {
|
||||
fontSize: 13,
|
||||
color: screenPalette.secondaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
gap: 32,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingBottom: 12,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 14,
|
||||
color: screenPalette.mutedText,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
tabIndicator: {
|
||||
marginTop: 10,
|
||||
height: 2,
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 1,
|
||||
},
|
||||
tabIndicatorActive: {
|
||||
backgroundColor: screenPalette.accent,
|
||||
},
|
||||
tabDivider: {
|
||||
marginTop: 12,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: screenPalette.divider,
|
||||
},
|
||||
packGrid: {
|
||||
marginTop: 28,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 18,
|
||||
},
|
||||
packCard: {
|
||||
width: '47%',
|
||||
backgroundColor: screenPalette.surfaceRaised,
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
packCardSelected: {
|
||||
borderColor: screenPalette.accent,
|
||||
backgroundColor: 'rgba(254, 184, 64, 0.05)',
|
||||
},
|
||||
packHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
packPoints: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
subscriptionEmpty: {
|
||||
marginTop: 64,
|
||||
backgroundColor: screenPalette.surface,
|
||||
borderRadius: 22,
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 13,
|
||||
color: screenPalette.secondaryText,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 300,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: screenPalette.secondaryText,
|
||||
},
|
||||
subscriptionGrid: {
|
||||
marginTop: 28,
|
||||
gap: 16,
|
||||
},
|
||||
subscriptionCard: {
|
||||
backgroundColor: screenPalette.surfaceRaised,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
position: 'relative',
|
||||
},
|
||||
subscriptionCardSelected: {
|
||||
borderColor: screenPalette.accent,
|
||||
backgroundColor: 'rgba(254, 184, 64, 0.05)',
|
||||
},
|
||||
subscriptionCardHighlight: {
|
||||
borderColor: screenPalette.button,
|
||||
},
|
||||
subscriptionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
subscriptionName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
subscriptionPriceContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 20,
|
||||
},
|
||||
subscriptionPrice: {
|
||||
fontSize: 40,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.accent,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
subscriptionInterval: {
|
||||
fontSize: 16,
|
||||
color: screenPalette.secondaryText,
|
||||
marginLeft: 4,
|
||||
},
|
||||
subscriptionCreditsBox: {
|
||||
backgroundColor: 'rgba(254, 184, 64, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
creditsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subscriptionCreditsValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.accent,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
creditsLabel: {
|
||||
fontSize: 13,
|
||||
color: screenPalette.secondaryText,
|
||||
fontWeight: '500',
|
||||
},
|
||||
popularBadge: {
|
||||
backgroundColor: screenPalette.button,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
popularBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.buttonText,
|
||||
},
|
||||
currentBadge: {
|
||||
marginTop: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.15)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 10,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
currentBadgeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#22c55e',
|
||||
},
|
||||
canceledBadge: {
|
||||
marginTop: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: 'rgba(251, 146, 60, 0.15)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 10,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
canceledBadgeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#fb923c',
|
||||
},
|
||||
customAmountContainer: {
|
||||
marginTop: 24,
|
||||
backgroundColor: screenPalette.surfaceRaised,
|
||||
borderRadius: 22,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 24,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
customAmountContainerActive: {
|
||||
borderColor: screenPalette.accent,
|
||||
backgroundColor: 'rgba(254, 184, 64, 0.05)',
|
||||
},
|
||||
customAmountInput: {
|
||||
backgroundColor: screenPalette.surface,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: screenPalette.primaryText,
|
||||
borderWidth: 1,
|
||||
borderColor: screenPalette.divider,
|
||||
},
|
||||
bottomBar: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
primaryButton: {
|
||||
height: 54,
|
||||
borderRadius: 27,
|
||||
backgroundColor: screenPalette.button,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
primaryButtonLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.buttonText,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
5
app/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/home" />;
|
||||
}
|
||||
29
app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
297
app/points.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import type { ListRenderItem } from 'react-native';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useBalance } from '@/hooks/use-balance';
|
||||
import { useTransactions } from '@/hooks/use-transactions';
|
||||
import type { Transaction, TransactionKind } from '@/lib/api/transactions';
|
||||
|
||||
type FilterKey = 'all' | TransactionKind;
|
||||
|
||||
const screenPalette = {
|
||||
background: '#080808',
|
||||
surface: '#101010',
|
||||
surfaceActive: '#1C1C1C',
|
||||
surfaceOutline: '#1D1D1D',
|
||||
divider: '#161616',
|
||||
primaryText: '#F5F5F5',
|
||||
secondaryText: '#6C6C6C',
|
||||
accent: '#FEB840',
|
||||
accentHalo: 'rgba(254, 184, 64, 0.22)',
|
||||
negative: '#8F8F8F',
|
||||
};
|
||||
|
||||
const FILTERS: { key: FilterKey; label: string }[] = [
|
||||
{ key: 'all', label: 'All records' },
|
||||
{ key: 'consumed', label: 'Consumed' },
|
||||
{ key: 'obtained', label: 'Obtained' },
|
||||
];
|
||||
|
||||
export default function PointsDetailsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
|
||||
|
||||
const { balance, isLoading: isBalanceLoading, refresh: refreshBalance } = useBalance();
|
||||
const {
|
||||
transactions,
|
||||
isLoading: isTransactionsLoading,
|
||||
refresh: refreshTransactions,
|
||||
} = useTransactions();
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([refreshBalance(), refreshTransactions()]);
|
||||
setRefreshing(false);
|
||||
}, [refreshBalance, refreshTransactions]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (activeFilter === 'all') {
|
||||
return transactions;
|
||||
}
|
||||
|
||||
return transactions.filter(entry => entry.kind === activeFilter);
|
||||
}, [activeFilter, transactions]);
|
||||
|
||||
const listContentStyle = useMemo(
|
||||
() => ({
|
||||
paddingBottom: Math.max(insets.bottom + 36, 72),
|
||||
paddingTop: 4,
|
||||
}),
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((entry: Transaction) => entry.id, []);
|
||||
|
||||
const renderSeparator = useCallback(() => <View style={styles.listDivider} />, []);
|
||||
|
||||
const renderLedgerEntry: ListRenderItem<Transaction> = useCallback(({ item }) => {
|
||||
const amount = Math.abs(item.amount);
|
||||
const isPositive = item.amount > 0;
|
||||
const displayDate = new Date(item.happenedAt).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.recordRow}>
|
||||
<View>
|
||||
<Text style={styles.recordTitle}>{item.title}</Text>
|
||||
<Text style={styles.recordTimestamp}>{displayDate}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.recordValue,
|
||||
isPositive ? styles.valuePositive : styles.valueNegative,
|
||||
]}
|
||||
>
|
||||
{isPositive ? `+ ${amount}` : `- ${amount}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const isLoading = isBalanceLoading || isTransactionsLoading;
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{isLoading && !refreshing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={screenPalette.accent} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.balanceCluster}>
|
||||
<View style={styles.energyOrb}>
|
||||
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
||||
</View>
|
||||
<Text style={styles.balanceValue}>{balance.remainingTokenBalance}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.segmentRail}>
|
||||
{FILTERS.map(filter => {
|
||||
const isActive = filter.key === activeFilter;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={filter.key}
|
||||
style={[styles.segmentChip, isActive && styles.segmentChipActive]}
|
||||
onPress={() => setActiveFilter(filter.key)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filteredEntries}
|
||||
keyExtractor={keyExtractor}
|
||||
style={styles.list}
|
||||
contentContainerStyle={listContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ItemSeparatorComponent={renderSeparator}
|
||||
renderItem={renderLedgerEntry}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={screenPalette.accent}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: screenPalette.background,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 48,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 17,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
balanceCluster: {
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
marginBottom: 28,
|
||||
gap: 10,
|
||||
},
|
||||
energyOrb: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: screenPalette.accentHalo,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: screenPalette.accent,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 8,
|
||||
},
|
||||
balanceValue: {
|
||||
fontSize: 46,
|
||||
lineHeight: 52,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
segmentRail: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: screenPalette.surface,
|
||||
padding: 4,
|
||||
borderRadius: 28,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: screenPalette.surfaceOutline,
|
||||
gap: 4,
|
||||
},
|
||||
segmentChip: {
|
||||
flex: 1,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 11,
|
||||
},
|
||||
segmentChipActive: {
|
||||
backgroundColor: screenPalette.surfaceActive,
|
||||
},
|
||||
segmentLabel: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: screenPalette.secondaryText,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
segmentLabelActive: {
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
marginTop: 28,
|
||||
},
|
||||
listDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: screenPalette.divider,
|
||||
marginVertical: 0,
|
||||
},
|
||||
recordRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
},
|
||||
recordTitle: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
recordTimestamp: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: screenPalette.secondaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
recordValue: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
fontWeight: '600',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
valuePositive: {
|
||||
color: screenPalette.accent,
|
||||
},
|
||||
valueNegative: {
|
||||
color: screenPalette.negative,
|
||||
},
|
||||
});
|
||||
676
app/result.tsx
Normal file
@@ -0,0 +1,676 @@
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { getTemplateGeneration } from '@/lib/api/template-runs';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { documentDirectory, downloadAsync } from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
Maximize2,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Image, Modal, Platform, ScrollView,
|
||||
StyleSheet, Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持
|
||||
const ResizeMode = {
|
||||
CONTAIN: 'contain' as const,
|
||||
COVER: 'cover' as const,
|
||||
STRETCH: 'fill' as const, // 使用 'fill' 替代 'stretch'
|
||||
};
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = screenWidth * 0.58;
|
||||
const VIDEO_HEIGHT = screenHeight * 0.6;
|
||||
|
||||
// 原生视频播放组件
|
||||
const NativeVideoPlayer: React.FC<{ url: string; style?: any }> = ({ url, style }) => {
|
||||
const player = useVideoPlayer(url, player => {
|
||||
player.loop = false;
|
||||
});
|
||||
|
||||
return (
|
||||
<VideoView
|
||||
player={player}
|
||||
style={style}
|
||||
contentFit="contain"
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResultWithTemplate {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string;
|
||||
type: 'UNKNOWN' | 'TEXT' | 'IMAGE' | 'VIDEO';
|
||||
resultUrl: string[];
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
creditsCost?: number;
|
||||
creditsTransactionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
template?: {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件 URL 后缀名判断媒体类型
|
||||
*/
|
||||
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||
|
||||
if (imageExts.includes(extension)) return 'image';
|
||||
if (videoExts.includes(extension)) return 'video';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取媒体类型,过滤掉 unknown
|
||||
*/
|
||||
function getSafeMediaType(url: string): 'image' | 'video' {
|
||||
const type = getMediaType(url);
|
||||
return type === 'unknown' ? 'image' : type;
|
||||
}
|
||||
|
||||
export default function ResultPage() {
|
||||
const { generationId } = useLocalSearchParams<{ generationId: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [result, setResult] = useState<ResultWithTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fullscreenVisible, setFullscreenVisible] = useState(false);
|
||||
const [fullscreenContent, setFullscreenContent] = useState<{
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (generationId) {
|
||||
loadResult();
|
||||
}
|
||||
}, [generationId]);
|
||||
|
||||
const loadResult = async () => {
|
||||
if (!generationId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getTemplateGeneration(generationId);
|
||||
|
||||
if (response?.success && response.data) {
|
||||
setResult(response.data as any);
|
||||
} else {
|
||||
Alert.alert('错误', '无法加载结果详情');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load result:', error);
|
||||
Alert.alert('错误', '加载结果失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRerun = () => {
|
||||
router.push(`/templates/${result?.templateId}/form`);
|
||||
};
|
||||
|
||||
const handleSaveToGallery = async () => {
|
||||
if (!result || result.type === 'TEXT') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Web 平台处理
|
||||
if (Platform.OS === 'web') {
|
||||
for (const url of result.resultUrl) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// 类型安全的处理方式
|
||||
if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) {
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = url.split('/').pop() || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
// 添加延迟,避免浏览器阻止多个下载
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
} catch (error) {
|
||||
console.error('Web download failed:', url, error);
|
||||
}
|
||||
}
|
||||
|
||||
const count = result.resultUrl.length;
|
||||
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
|
||||
Alert.alert(
|
||||
'下载开始',
|
||||
count > 1 ? `正在下载${count}个${mediaType}` : `正在下载${mediaType}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 原生平台处理
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量下载所有媒体文件
|
||||
let successCount = 0;
|
||||
for (const url of result.resultUrl) {
|
||||
try {
|
||||
// 生成临时文件名
|
||||
const filename = url.split('/').pop() || `download_${Date.now()}`;
|
||||
|
||||
if (!documentDirectory) {
|
||||
console.error('Document directory not available');
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileUri = `${documentDirectory}${filename}`;
|
||||
|
||||
// 下载文件到本地
|
||||
const downloadResult = await downloadAsync(url, fileUri);
|
||||
|
||||
if (downloadResult.status === 200) {
|
||||
// 保存到相册
|
||||
const asset = await MediaLibrary.createAssetAsync(downloadResult.uri);
|
||||
successCount++;
|
||||
} else {
|
||||
console.error('Download failed with status:', downloadResult.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download and save:', url, error);
|
||||
}
|
||||
}
|
||||
|
||||
const count = result.resultUrl.length;
|
||||
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
|
||||
|
||||
if (successCount === count) {
|
||||
Alert.alert(
|
||||
'保存成功',
|
||||
count > 1 ? `${count}个${mediaType}已保存到相册` : `${mediaType}已保存到相册`
|
||||
);
|
||||
} else if (successCount > 0) {
|
||||
Alert.alert(
|
||||
'部分保存成功',
|
||||
`${successCount}/${count}个${mediaType}已保存到相册`
|
||||
);
|
||||
} else {
|
||||
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save to gallery error:', error);
|
||||
Alert.alert('保存失败', '无法保存文件,请稍后重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = async () => {
|
||||
if (!result || result.type !== 'TEXT') return;
|
||||
|
||||
try {
|
||||
await Clipboard.setStringAsync(result.resultUrl[0]);
|
||||
Alert.alert('复制成功', '文本已复制到剪贴板');
|
||||
} catch (error) {
|
||||
Alert.alert('复制失败', '无法复制文本');
|
||||
}
|
||||
};
|
||||
|
||||
const openFullscreen = (type: 'image' | 'video', url: string) => {
|
||||
setFullscreenContent({ type, url });
|
||||
setFullscreenVisible(true);
|
||||
};
|
||||
|
||||
const handleDownloadPress = () => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.type === 'TEXT') {
|
||||
handleCopyText();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSaveToGallery();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#D1FF00" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>未找到结果</Text>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextResult = result.type === 'TEXT';
|
||||
const mediaUrls = result.resultUrl || [];
|
||||
const hasMultipleMedia = mediaUrls.length > 1;
|
||||
|
||||
const renderMediaItem = (url: string, index: number) => {
|
||||
const mediaType = getMediaType(url);
|
||||
|
||||
return (
|
||||
<View key={`media-${index}`} style={{ width: screenWidth, backgroundColor: '#000' }}>
|
||||
{mediaType === 'video' ? (
|
||||
Platform.OS === 'web' ? (
|
||||
<video
|
||||
src={url}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: VIDEO_HEIGHT,
|
||||
objectFit: 'contain' as any,
|
||||
}}
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<NativeVideoPlayer
|
||||
url={url}
|
||||
style={{ width: '100%', height: VIDEO_HEIGHT }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={{ width: '100%', height: VIDEO_HEIGHT }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const handleScroll = (event: any) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
const index = Math.round(offsetX / screenWidth);
|
||||
setCurrentMediaIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={[styles.safeGuard, { paddingTop: insets.top || 16 }]} />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.contentPadding}>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.7}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={22} color="#F6F6F8" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{result.template?.title || '生成结果'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{result.template?.titleEn || ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isTextResult ? (
|
||||
<View style={styles.heroCard}>
|
||||
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.generatedText}>{mediaUrls[0]}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.mediaContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{mediaUrls.map((url, index) => renderMediaItem(url, index))}
|
||||
</ScrollView>
|
||||
|
||||
{hasMultipleMedia && (
|
||||
<View style={styles.pagination}>
|
||||
{mediaUrls.map((_, index) => (
|
||||
<View
|
||||
key={`dot-${index}`}
|
||||
style={[
|
||||
styles.paginationDot,
|
||||
index === currentMediaIndex && styles.paginationDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.expandButton}
|
||||
onPress={() =>
|
||||
openFullscreen(
|
||||
getSafeMediaType(mediaUrls[currentMediaIndex]),
|
||||
mediaUrls[currentMediaIndex]
|
||||
)
|
||||
}
|
||||
>
|
||||
<Maximize2 size={18} color="#F5F5F7" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={fullscreenVisible}
|
||||
transparent={false}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFullscreenVisible(false)}
|
||||
>
|
||||
<View style={styles.fullscreenContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => setFullscreenVisible(false)}
|
||||
>
|
||||
<X size={30} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{fullscreenContent && (
|
||||
<View style={styles.fullscreenMedia}>
|
||||
{fullscreenContent.type === 'image' ? (
|
||||
<Image
|
||||
source={{ uri: fullscreenContent.url }}
|
||||
style={styles.fullscreenMediaContent}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{Platform.OS === 'web' ? (
|
||||
<video
|
||||
src={fullscreenContent.url}
|
||||
style={styles.fullscreenMediaContent as any}
|
||||
autoPlay
|
||||
controls
|
||||
/>
|
||||
) : (
|
||||
<NativeVideoPlayer
|
||||
url={fullscreenContent.url}
|
||||
style={styles.fullscreenMediaContent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 20),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
style={styles.secondaryAction}
|
||||
onPress={handleRerun}
|
||||
>
|
||||
<Plus size={20} color="#F6F6F8" />
|
||||
<Text style={styles.secondaryText}>Regenerate</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.primaryAction,
|
||||
saving && { opacity: 0.7 },
|
||||
]}
|
||||
onPress={handleDownloadPress}
|
||||
disabled={saving}
|
||||
>
|
||||
{isTextResult ? (
|
||||
<Copy size={20} color="#1C1C1E" />
|
||||
) : (
|
||||
<Download size={20} color="#1C1C1E" />
|
||||
)}
|
||||
<Text style={styles.primaryText}>
|
||||
{isTextResult ? 'Copy Text' : 'Download'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#09090B',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#09090B',
|
||||
},
|
||||
loadingText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
},
|
||||
safeGuard: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 160,
|
||||
},
|
||||
contentPadding: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(32, 32, 36, 0.6)',
|
||||
},
|
||||
title: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
lineHeight: 30,
|
||||
marginBottom: 12,
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
subtitle: {
|
||||
color: '#9A9AA2',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
heroCard: {
|
||||
marginTop: 32,
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#131317',
|
||||
padding: 24,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mediaContainer: {
|
||||
marginTop: 32,
|
||||
position: 'relative',
|
||||
backgroundColor: '#09090B',
|
||||
},
|
||||
carouselContent: {
|
||||
gap: 0,
|
||||
},
|
||||
pagination: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
paginationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
paginationDotActive: {
|
||||
backgroundColor: '#D9FF3F',
|
||||
width: 24,
|
||||
},
|
||||
expandButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
textScroll: {
|
||||
maxHeight: HERO_HEIGHT,
|
||||
},
|
||||
generatedText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
right: 20,
|
||||
zIndex: 1,
|
||||
width: 50,
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fullscreenMedia: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fullscreenMediaContent: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 16,
|
||||
backgroundColor: 'rgba(6, 6, 7, 0.94)',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryAction: {
|
||||
flex: 1,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#151519',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
marginRight: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
primaryAction: {
|
||||
flex: 1,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#D9FF3F',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#1C1C1E',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
31
app/settings/about.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ScrollView } from 'react-native';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export default function AboutScreen() {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title">关于我们</ThemedText>
|
||||
<ThemedText>此页面正在开发中...</ThemedText>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
});
|
||||
31
app/settings/account.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ScrollView } from 'react-native';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export default function AccountSettingsScreen() {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title">账户设置</ThemedText>
|
||||
<ThemedText>此页面正在开发中...</ThemedText>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
});
|
||||
211
app/settings/index.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { type Href, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
|
||||
type Palette = {
|
||||
canvas: string;
|
||||
card: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
icon: string;
|
||||
stroke: string;
|
||||
buttonBackground: string;
|
||||
buttonText: string;
|
||||
};
|
||||
|
||||
type SettingDestination = {
|
||||
key: string;
|
||||
label: string;
|
||||
route?: Href;
|
||||
};
|
||||
|
||||
const palettes: Record<'dark' | 'light', Palette> = {
|
||||
dark: {
|
||||
canvas: '#050505',
|
||||
card: '#141417',
|
||||
textPrimary: '#F5F5F7',
|
||||
textSecondary: '#8E8E93',
|
||||
icon: '#F5F5F7',
|
||||
stroke: '#232327',
|
||||
buttonBackground: '#FFFFFF',
|
||||
buttonText: '#050505',
|
||||
},
|
||||
light: {
|
||||
canvas: '#F7F7F9',
|
||||
card: '#FFFFFF',
|
||||
textPrimary: '#131318',
|
||||
textSecondary: '#5C5C67',
|
||||
icon: '#131318',
|
||||
stroke: '#E4E4EA',
|
||||
buttonBackground: '#131318',
|
||||
buttonText: '#FFFFFF',
|
||||
},
|
||||
};
|
||||
|
||||
const destinations: SettingDestination[] = [
|
||||
{ key: 'change-password', label: 'Change Password', route: '/settings/account' },
|
||||
{ key: 'terms', label: 'Terms and Policies' },
|
||||
{ key: 'support', label: 'Feedback and Support' },
|
||||
];
|
||||
|
||||
export default function SettingsHomeScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const palette = palettes['dark'];
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const topInset = Math.max(insets.top, 16);
|
||||
const bottomInset = Math.max(insets.bottom + 16, 40);
|
||||
|
||||
const handleNavigate = (destination: SettingDestination) => {
|
||||
if (!destination.route) {
|
||||
return;
|
||||
}
|
||||
router.push(destination.route);
|
||||
};
|
||||
|
||||
const handleLogOut = async () => {
|
||||
if (isLoggingOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoggingOut(true);
|
||||
|
||||
try {
|
||||
await authClient.signOut();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Please try again in a moment.';
|
||||
Alert.alert('Unable to log out', message);
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { paddingTop: topInset, backgroundColor: palette.canvas }]}>
|
||||
<StatusBar style={'light'} />
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={styles.headerControl}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={palette.icon} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: palette.textPrimary }]}>set up</Text>
|
||||
<View style={styles.headerControl} />
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.card, { backgroundColor: palette.card }]}>
|
||||
{destinations.map((destination, index) => {
|
||||
const isLast = index === destinations.length - 1;
|
||||
return (
|
||||
<Pressable
|
||||
key={destination.key}
|
||||
accessibilityRole={destination.route ? 'button' : 'none'}
|
||||
disabled={!destination.route}
|
||||
onPress={() => handleNavigate(destination)}
|
||||
style={({ pressed }) => [
|
||||
styles.optionRow,
|
||||
!isLast && { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: palette.stroke },
|
||||
pressed && destination.route && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.optionLabel, { color: palette.textPrimary }]}>
|
||||
{destination.label}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={palette.textSecondary} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: bottomInset }]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={isLoggingOut}
|
||||
onPress={handleLogOut}
|
||||
style={({ pressed }) => [
|
||||
styles.logoutButton,
|
||||
{ backgroundColor: palette.buttonBackground },
|
||||
pressed && { opacity: 0.9 },
|
||||
]}
|
||||
>
|
||||
{isLoggingOut ? (
|
||||
<ActivityIndicator size="small" color={palette.buttonText} />
|
||||
) : (
|
||||
<Text style={[styles.logoutLabel, { color: palette.buttonText }]}>Log out</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 32,
|
||||
},
|
||||
headerControl: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 64,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 32,
|
||||
},
|
||||
logoutButton: {
|
||||
borderRadius: 26,
|
||||
height: 56,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logoutLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
31
app/settings/notification.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ScrollView } from 'react-native';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export default function NotificationSettingsScreen() {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title">通知设置</ThemedText>
|
||||
<ThemedText>此页面正在开发中...</ThemedText>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
});
|
||||
597
app/template/[id]/run.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { ResultDisplay } from '@/components/template-run/result-display';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useTemplateRun } from '@/hooks/use-template-run';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import { subscription } from '@/lib/auth/client';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { RunTemplateData } from '@/lib/types/template-run';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, BackHandler, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type RunStep = 'progress' | 'result';
|
||||
|
||||
function transformFormData(formData: RunTemplateData): RunTemplateData {
|
||||
const transformed: RunTemplateData = {};
|
||||
|
||||
Object.entries(formData).forEach(([fieldName, value]) => {
|
||||
if (fieldName.startsWith('image_')) {
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'image',
|
||||
};
|
||||
} else if (fieldName.startsWith('text_')) {
|
||||
transformed[fieldName] = {
|
||||
content: value,
|
||||
type: 'text',
|
||||
};
|
||||
} else if (fieldName.startsWith('video_')) {
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'video',
|
||||
};
|
||||
} else if (fieldName.startsWith('color_')) {
|
||||
transformed[fieldName] = {
|
||||
value,
|
||||
type: 'color',
|
||||
};
|
||||
} else {
|
||||
transformed[fieldName] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
export default function TemplateRunScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const globalParams = useGlobalSearchParams<{ formData?: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentStep, setCurrentStep] = useState<RunStep>('progress');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [formImageUrls, setFormImageUrls] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
progress,
|
||||
result,
|
||||
error,
|
||||
isLoading,
|
||||
executeTemplate,
|
||||
cancelRun,
|
||||
cleanup,
|
||||
} = useTemplateRun({
|
||||
onSuccess: () => {
|
||||
setCurrentStep('result');
|
||||
},
|
||||
onError: (runError) => {
|
||||
Alert.alert('运行失败', runError.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadTemplate = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getTemplateById(id);
|
||||
if (response && response.success && response.data) {
|
||||
setTemplate(response.data as any);
|
||||
} else {
|
||||
throw new Error('模板不存在');
|
||||
}
|
||||
} catch (templateError) {
|
||||
console.error('加载模板失败:', templateError);
|
||||
Alert.alert('错误', '无法加载模板,请稍后重试。');
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplate();
|
||||
}, [id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (template && !hasStarted && globalParams.formData) {
|
||||
try {
|
||||
const formData = JSON.parse(globalParams.formData) as RunTemplateData;
|
||||
const images = Object.entries(formData)
|
||||
.filter(([fieldName, value]) => fieldName.startsWith('image_') && typeof value === 'string' && value.length > 0)
|
||||
.map(([, value]) => value as string);
|
||||
setFormImageUrls(images);
|
||||
setHasStarted(true);
|
||||
|
||||
const executeWithData = async () => {
|
||||
try {
|
||||
const list = await subscription.list();
|
||||
const listData = list.data ?? [];
|
||||
if (listData.length === 0) {
|
||||
Alert.alert('未检测到订阅', '请先完成订阅后再尝试生成内容。');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = listData[0]?.stripeSubscriptionId;
|
||||
if (!subscriptionId) {
|
||||
Alert.alert('订阅信息缺失', '当前订阅缺少 Stripe 订阅标识,无法记录用量。');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const identify = await subscription.meterEvent({
|
||||
event_name: 'token_usage',
|
||||
payload: {
|
||||
value: '100',
|
||||
},
|
||||
});
|
||||
|
||||
const identifier = identify.data?.identifier;
|
||||
if (!identifier) {
|
||||
Alert.alert('用量记录失败', '无法生成用量凭证,请稍后重试。');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const transformedData = transformFormData(formData);
|
||||
await executeTemplate(template.id, transformedData);
|
||||
} catch (executeError) {
|
||||
console.error('执行模板失败:', executeError);
|
||||
Alert.alert('执行失败', '运行模板时出现异常,请稍后重试。');
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
executeWithData();
|
||||
} catch (parseError) {
|
||||
console.error('解析表单数据失败:', parseError);
|
||||
Alert.alert('数据错误', '表单数据格式错误,请重新提交。');
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
}, [template, hasStarted, globalParams.formData, executeTemplate, router]);
|
||||
|
||||
const handleRequestExit = useCallback(() => {
|
||||
if (currentStep === 'progress' && isLoading) {
|
||||
Alert.alert(
|
||||
'确认退出',
|
||||
'任务正在运行中,确定要退出吗?',
|
||||
[
|
||||
{ text: '继续等待', style: 'cancel' },
|
||||
{
|
||||
text: '停止并返回',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
cancelRun();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
router.back();
|
||||
return true;
|
||||
}, [cancelRun, currentStep, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleRequestExit);
|
||||
return () => subscription.remove();
|
||||
}, [handleRequestExit]);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const handleRerun = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const progressMeta = useMemo(() => {
|
||||
const rawProgress = typeof progress.progress === 'number' ? progress.progress : 0;
|
||||
const normalized = Math.max(0, Math.min(rawProgress, 100));
|
||||
return {
|
||||
normalized,
|
||||
percentLabel: Math.round(normalized),
|
||||
fillWidth: normalized <= 0 ? 0 : Math.min(Math.max(normalized, 8), 100),
|
||||
};
|
||||
}, [progress.progress]);
|
||||
|
||||
const narrative = useMemo(() => {
|
||||
const fallbackTitle = 'Insert Your Product Into An ASMR Video';
|
||||
const fallbackDescription =
|
||||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.';
|
||||
|
||||
const rawTitle = template?.titleEn ?? template?.title ?? fallbackTitle;
|
||||
const rawDescription = template?.descriptionEn ?? template?.description ?? fallbackDescription;
|
||||
|
||||
return {
|
||||
title: rawTitle.toUpperCase(),
|
||||
description: rawDescription,
|
||||
};
|
||||
}, [template]);
|
||||
|
||||
const imagery = useMemo(() => {
|
||||
const fallback = template?.previewUrl ?? template?.coverImageUrl ?? undefined;
|
||||
const primary = formImageUrls[0] ?? fallback;
|
||||
const secondary = formImageUrls[1] ?? template?.previewUrl ?? primary;
|
||||
|
||||
return {
|
||||
hero: primary,
|
||||
overlay: secondary,
|
||||
preview: template?.previewUrl ?? primary ?? fallback,
|
||||
};
|
||||
}, [formImageUrls, template]);
|
||||
|
||||
const renderProgressStep = () => (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.progressContent,
|
||||
{
|
||||
paddingTop: insets.top + 16,
|
||||
paddingBottom: insets.bottom + 40,
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handleRequestExit}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.backButton, styles.backButtonProgress]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.titleSection}>
|
||||
<ThemedText style={styles.title} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||||
{narrative.title}
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
style={styles.subtitle}
|
||||
lightColor="rgba(255,255,255,0.68)"
|
||||
darkColor="rgba(255,255,255,0.68)"
|
||||
>
|
||||
{narrative.description}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroContainer}>
|
||||
{imagery.hero ? (
|
||||
<Image source={{ uri: imagery.hero }} style={styles.heroImage} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.heroPlaceholder} />
|
||||
)}
|
||||
|
||||
{imagery.overlay && (
|
||||
<View style={styles.overlayImageFrame}>
|
||||
<Image source={{ uri: imagery.overlay }} style={styles.overlayImage} contentFit="cover" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<LinearGradient colors={['#1E1E21', '#101015']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.progressCard}>
|
||||
{imagery.preview ? (
|
||||
<View style={styles.previewFrame}>
|
||||
<Image source={{ uri: imagery.preview }} style={styles.previewImage} contentFit="cover" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.previewFrame, styles.previewPlaceholder]} />
|
||||
)}
|
||||
|
||||
<View style={styles.progressCopy}>
|
||||
<ThemedText style={styles.progressHeadline} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||||
✨ 正在加载生成中...
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
style={styles.progressMessage}
|
||||
lightColor="rgba(255,255,255,0.72)"
|
||||
darkColor="rgba(255,255,255,0.72)"
|
||||
>
|
||||
{progress.message || '正在为你织造 ASMR 场景,请稍候片刻。'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBarArea}>
|
||||
<View style={styles.progressTrack}>
|
||||
{progressMeta.fillWidth > 0 && (
|
||||
<View style={[styles.progressFill, { width: `${progressMeta.fillWidth}%` }]}>
|
||||
<LinearGradient
|
||||
colors={['#78F8FF', '#49A7FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<ThemedText
|
||||
style={styles.progressPercent}
|
||||
lightColor="rgba(255,255,255,0.6)"
|
||||
darkColor="rgba(255,255,255,0.6)"
|
||||
>
|
||||
{progressMeta.percentLabel}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.primaryButton}
|
||||
onPress={() => {
|
||||
Alert.alert('生成进行中', '系统正在为你制作视频,请耐心等待。');
|
||||
}}
|
||||
>
|
||||
<ThemedText style={styles.primaryButtonText} lightColor="#050505" darkColor="#050505">
|
||||
Generate Video
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderResultStep = () => (
|
||||
<View
|
||||
style={[
|
||||
styles.resultContainer,
|
||||
{
|
||||
paddingTop: insets.top + 16,
|
||||
paddingBottom: Math.max(insets.bottom, 24),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.resultHeader}>
|
||||
<TouchableOpacity
|
||||
onPress={handleRequestExit}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.backButton, styles.backButtonResult]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
<ThemedText style={styles.resultTitle} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||||
生成结果
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultBody}>
|
||||
{result ? (
|
||||
<ResultDisplay result={result} onRerun={handleRerun} />
|
||||
) : (
|
||||
<View style={styles.stateContainer}>
|
||||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||||
正在整理结果,请稍候...
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<View style={styles.stateContainer}>
|
||||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||||
加载中...
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !template) {
|
||||
return (
|
||||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<View style={styles.stateContainer}>
|
||||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||||
{error.message}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
gestureEnabled: currentStep !== 'progress' || !isLoading,
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentStep === 'progress' && renderProgressStep()}
|
||||
{currentStep === 'result' && renderResultStep()}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
progressContent: {
|
||||
paddingHorizontal: 24,
|
||||
gap: 28,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backButtonProgress: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
backButtonResult: {
|
||||
marginRight: 12,
|
||||
},
|
||||
titleSection: {
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.8,
|
||||
lineHeight: 28,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
heroContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#121212',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 1.58,
|
||||
},
|
||||
heroPlaceholder: {
|
||||
width: '100%',
|
||||
aspectRatio: 1.58,
|
||||
backgroundColor: '#1F1F20',
|
||||
},
|
||||
overlayImageFrame: {
|
||||
position: 'absolute',
|
||||
left: 18,
|
||||
bottom: 18,
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 2,
|
||||
borderColor: '#050505',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
overlayImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
progressCard: {
|
||||
borderRadius: 28,
|
||||
paddingVertical: 28,
|
||||
paddingHorizontal: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
gap: 24,
|
||||
},
|
||||
previewFrame: {
|
||||
alignSelf: 'center',
|
||||
width: 96,
|
||||
height: 148,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
backgroundColor: '#0F0F12',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
previewPlaceholder: {
|
||||
backgroundColor: '#18181C',
|
||||
},
|
||||
progressCopy: {
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
progressHeadline: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
progressMessage: {
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
progressBarArea: {
|
||||
gap: 12,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressPercent: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
primaryButton: {
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#D7FF1F',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#D7FF1F',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 4,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
resultContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 24,
|
||||
gap: 24,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
resultBody: {
|
||||
flex: 1,
|
||||
},
|
||||
stateContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
stateText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
301
app/templates/[id].tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { GenerateBtn } from '@/components/sker/generate-btn';
|
||||
import { Header } from '@/components/sker/header';
|
||||
import { ImgTab } from '@/components/sker/img-tab';
|
||||
import { Page } from '@/components/sker/page';
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player';
|
||||
import { getTagByName } from '@/lib/api/tags';
|
||||
import Feather from '@expo/vector-icons/Feather';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
type TemplateFrame = {
|
||||
id: string;
|
||||
uri: string;
|
||||
avatar?: string;
|
||||
title?: string;
|
||||
titleEn?: string;
|
||||
};
|
||||
|
||||
|
||||
export default function TemplateDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const [looks, setLooks] = useState<TemplateFrame[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
const hydrateLooks = async () => {
|
||||
|
||||
try {
|
||||
const templateResponse = await getTagByName(id);
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (templateResponse?.success && templateResponse.data) {
|
||||
const template = templateResponse.data;
|
||||
const frames: TemplateFrame[] = template.templates.map(it => ({
|
||||
id: it.id,
|
||||
uri: it.previewUrl,
|
||||
title: it.title,
|
||||
titleEn: it.titleEn,
|
||||
avatar: it.coverImageUrl
|
||||
}));
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
setLooks(frames);
|
||||
setSelectedId(frames.find(f => f.id === id)?.id ?? frames[0]?.id ?? template.id);
|
||||
} else {
|
||||
if (signal.aborted) return;
|
||||
}
|
||||
} catch (templateError) {
|
||||
if (signal.aborted) return;
|
||||
console.warn('Failed to fetch template detail:', templateError);
|
||||
} finally { }
|
||||
};
|
||||
|
||||
hydrateLooks();
|
||||
|
||||
return () => abortControllerRef.current?.abort();
|
||||
}, [id]);
|
||||
|
||||
const current = useMemo(() => {
|
||||
if (looks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!selectedId) {
|
||||
return looks[0];
|
||||
}
|
||||
return looks.find(look => look.id === selectedId) ?? looks[0];
|
||||
}, [looks, selectedId]);
|
||||
|
||||
const infoTitle = useMemo(() => {
|
||||
return current?.titleEn ?? current?.title ?? 'Loading template'
|
||||
}, [current]);
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
if (current?.id) {
|
||||
router.push(`/templates/${current.id}/form`);
|
||||
}
|
||||
}, [current]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header title={infoTitle} />
|
||||
<ImgTab images={looks} activeId={selectedId} onActiveChange={(id) => {
|
||||
setSelectedId(id)
|
||||
}} />
|
||||
<ScrollView
|
||||
style={styles.body}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.heroStage}>
|
||||
<VideoPlayer
|
||||
source={{ uri: current?.uri! }}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
<GenerateBtn onGenerate={handleGenerate} menuItems={[]}>
|
||||
<View style={styles.bottomInfoRow}>
|
||||
<View style={styles.infoCard}>
|
||||
<Image source={{ uri: current?.avatar }} style={styles.infoThumbnail} />
|
||||
<Text style={styles.infoTitle} numberOfLines={1}>
|
||||
{infoTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GenerateBtn>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
flex: 1,
|
||||
backgroundColor: '#040404',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 40,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
loadingState: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
loadingLabel: {
|
||||
fontSize: 13,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
},
|
||||
errorBanner: {
|
||||
marginHorizontal: 24,
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255, 94, 94, 0.12)',
|
||||
},
|
||||
errorText: {
|
||||
color: '#FF8A8A',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: 'rgba(255, 94, 94, 0.2)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
retryLabel: {
|
||||
color: '#FF8A8A',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
gap: 12,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
headerPlaceholder: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
topCarousel: {
|
||||
marginTop: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
},
|
||||
emptyCarousel: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 80,
|
||||
},
|
||||
emptyCarouselText: {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: 13,
|
||||
},
|
||||
carouselContent: {
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
previewFrame: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
previewFrameActive: {
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroStage: {
|
||||
marginTop: 32,
|
||||
borderRadius: 36,
|
||||
backgroundColor: '#111111',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 32,
|
||||
shadowOffset: { width: 0, height: 18 },
|
||||
elevation: 24,
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: 456,
|
||||
borderRadius: 36,
|
||||
}, bottomInfoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginTop: 32,
|
||||
},
|
||||
infoCard: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 24,
|
||||
gap: 12,
|
||||
},
|
||||
infoThumbnail: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
marginRight: 0,
|
||||
rotation: 6
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
generateButton: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#D1FF00',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 18,
|
||||
},
|
||||
generateButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#050505',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
708
app/templates/[id]/form.tsx
Normal file
@@ -0,0 +1,708 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
export const unstable_settings = {
|
||||
headerShown: false,
|
||||
};
|
||||
|
||||
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import { recordTokenUsage, getUserBalance } from '@/lib/api/balance';
|
||||
import { runTemplate, pollTemplateGeneration } from '@/lib/api/template-runs';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
||||
import { TemplateGeneration } from '@/lib/types/template-run';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { Header } from '@/components/sker/header';
|
||||
import { Page } from '@/components/sker/page';
|
||||
import { GenerateBtn } from '@/components/sker/generate-btn';
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player';
|
||||
import { getVideoThumbnail } from '@/lib/utils/media';
|
||||
|
||||
export default function TemplateFormScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { session, isLoading: authLoading } = useAuth();
|
||||
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [pollingStatus, setPollingStatus] = useState<{
|
||||
isPolling: boolean;
|
||||
generationId?: string;
|
||||
message?: string;
|
||||
}>({ isPolling: false });
|
||||
|
||||
const pollCancelRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
|
||||
return () => {
|
||||
if (pollCancelRef.current) {
|
||||
pollCancelRef.current();
|
||||
pollCancelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const templateResponse = await getTemplateById(id);
|
||||
|
||||
if (templateResponse?.success && templateResponse?.data) {
|
||||
setTemplate(templateResponse.data as any);
|
||||
// 初始化表单数据
|
||||
const initialData: Record<string, any> = {};
|
||||
(templateResponse.data.formSchema as any)?.startNodes.forEach((node: TemplateGraphNode) => {
|
||||
if (node.data.actionData?.allowMultiple) {
|
||||
initialData[node.id] = [];
|
||||
} else {
|
||||
initialData[node.id] = '';
|
||||
}
|
||||
});
|
||||
setFormData(initialData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
Alert.alert('错误', '加载数据失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
if (!template?.formSchema?.startNodes) return false;
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
template.formSchema.startNodes.forEach((node: TemplateGraphNode) => {
|
||||
const { id, data } = node;
|
||||
const { actionData, label } = data;
|
||||
const value = formData[id];
|
||||
|
||||
// 检查必填项
|
||||
if (actionData?.required) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
newErrors[id] = `请选择${label}`;
|
||||
}
|
||||
} else if (!value || (typeof value === 'string' && !value.trim())) {
|
||||
newErrors[id] = `请填写${label}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否可以生成(登录 + metered 订阅 + 余额充足)
|
||||
*/
|
||||
const canGenerate = async (): Promise<{ canProceed: boolean; message?: string }> => {
|
||||
// 1. 检查是否登录
|
||||
if (!session?.user) {
|
||||
return {
|
||||
canProceed: false,
|
||||
message: '请先登录',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查是否有 metered 订阅
|
||||
const balanceResponse = await getUserBalance();
|
||||
if (!balanceResponse.success) {
|
||||
return {
|
||||
canProceed: false,
|
||||
message: balanceResponse.message || '未找到计费订阅,请先订阅',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 检查余额是否充足
|
||||
const requiredAmount = template?.costPrice || 0;
|
||||
const currentBalance = balanceResponse.data.remainingTokenBalance;
|
||||
|
||||
if (currentBalance < requiredAmount) {
|
||||
return {
|
||||
canProceed: false,
|
||||
message: `余额不足\n当前余额: ${currentBalance}\n需要费用: ${requiredAmount}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { canProceed: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传所有 blob URL 文件到服务器
|
||||
*/
|
||||
const uploadBlobFiles = async (data: Record<string, any>): Promise<Record<string, any>> => {
|
||||
if (!template?.formSchema?.startNodes) return data;
|
||||
|
||||
const uploadedData = { ...data };
|
||||
|
||||
for (const node of template.formSchema.startNodes) {
|
||||
const value = data[node.id];
|
||||
|
||||
if (!value) continue;
|
||||
|
||||
// 检查是否是 blob URL
|
||||
if (typeof value === 'string' && value.startsWith('blob:')) {
|
||||
const fileType = node.type === 'image' ? 'image' : node.type === 'video' ? 'video' : null;
|
||||
|
||||
if (fileType) {
|
||||
const uploadResponse = await uploadFile(value, fileType as 'image' | 'video');
|
||||
|
||||
if (!uploadResponse.success || !uploadResponse.data) {
|
||||
throw new Error(`上传 ${node.data.label} 失败`);
|
||||
}
|
||||
|
||||
uploadedData[node.id] = uploadResponse.data.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedData;
|
||||
};
|
||||
|
||||
const transformFormDataToRunFormat = (formData: Record<string, any>): Record<string, any> => {
|
||||
if (!template?.formSchema?.startNodes) return {};
|
||||
|
||||
const transformed: Record<string, any> = {};
|
||||
|
||||
template.formSchema.startNodes.forEach((node: TemplateGraphNode) => {
|
||||
const { id, type } = node;
|
||||
const value = formData[id];
|
||||
|
||||
// 跳过空值
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据节点类型转换数据格式
|
||||
switch (type) {
|
||||
case 'image':
|
||||
transformed[id] = {
|
||||
images: [{ url: value }],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
transformed[id] = {
|
||||
videos: [{ url: value }],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
transformed[id] = {
|
||||
texts: [value],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
// 根据 allowMultiple 判断是否为多选
|
||||
if (node.data.actionData?.allowMultiple) {
|
||||
transformed[id] = {
|
||||
selections: Array.isArray(value) ? value : [value],
|
||||
};
|
||||
} else {
|
||||
transformed[id] = {
|
||||
selections: value,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 未知类型,保持原样
|
||||
transformed[id] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return transformed;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!template) return;
|
||||
if (!validateForm()) {
|
||||
Alert.alert('提示', '请完善所有必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 步骤 1: 检查是否可以生成(登录 + 订阅 + 余额)
|
||||
const checkResult = await canGenerate();
|
||||
if (!checkResult.canProceed) {
|
||||
Alert.alert('无法生成', checkResult.message || '请检查账户状态', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '去充值',
|
||||
onPress: () => router.push('/exchange' as any),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 步骤 2: 上传所有 blob 文件到服务器
|
||||
let uploadedFormData: Record<string, any>;
|
||||
try {
|
||||
uploadedFormData = await uploadBlobFiles(formData);
|
||||
} catch (uploadError) {
|
||||
Alert.alert('上传失败', uploadError instanceof Error ? uploadError.message : '文件上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 步骤 3: 扣费并获取 identifier
|
||||
const requiredAmount = template.costPrice || 0;
|
||||
const usageResponse = await recordTokenUsage({
|
||||
price: requiredAmount,
|
||||
name: `生成视频 - ${template.title}`,
|
||||
metadata: {
|
||||
templateId: template.id,
|
||||
templateTitle: template.title,
|
||||
},
|
||||
});
|
||||
|
||||
if (!usageResponse.success || !usageResponse.data?.identifier) {
|
||||
Alert.alert('扣费失败', usageResponse.message || '请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIdentifier = usageResponse.data.identifier;
|
||||
|
||||
// 步骤 4: 验证所有 URL 都是有效的服务器 URL
|
||||
const hasInvalidUrl = Object.values(uploadedFormData).some(
|
||||
(value) => typeof value === 'string' && value.startsWith('blob:')
|
||||
);
|
||||
|
||||
if (hasInvalidUrl) {
|
||||
Alert.alert('错误', '存在未上传的文件,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 步骤 5: 转换表单数据为 API 格式
|
||||
const transformedData = transformFormDataToRunFormat(uploadedFormData);
|
||||
|
||||
// 步骤 6: 调用 template run,使用支付凭证 identifier
|
||||
const runResponse = await runTemplate(id, transformedData, paymentIdentifier);
|
||||
|
||||
if (!runResponse?.success) {
|
||||
Alert.alert('错误', '生成任务创建失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const generationId = runResponse.data;
|
||||
|
||||
// 步骤 7: 开始轮询任务状态
|
||||
setPollingStatus({
|
||||
isPolling: true,
|
||||
generationId,
|
||||
message: '正在生成内容,请稍候...',
|
||||
});
|
||||
|
||||
// 清理之前的轮询
|
||||
if (pollCancelRef.current) {
|
||||
pollCancelRef.current();
|
||||
}
|
||||
|
||||
// 使用轮询函数
|
||||
const cancelPoll = pollTemplateGeneration(
|
||||
generationId,
|
||||
// 成功回调
|
||||
(result: TemplateGeneration) => {
|
||||
setPollingStatus({ isPolling: false });
|
||||
pollCancelRef.current = null;
|
||||
|
||||
// 跳转到结果页面
|
||||
router.push(`/result?generationId=${generationId}`);
|
||||
},
|
||||
// 错误回调
|
||||
(error: Error) => {
|
||||
setPollingStatus({ isPolling: false });
|
||||
pollCancelRef.current = null;
|
||||
Alert.alert('生成失败', error.message || '任务执行失败,请重试');
|
||||
},
|
||||
// 最大尝试次数(100次 * 3秒 = 5分钟)
|
||||
100,
|
||||
// 轮询间隔(3秒)
|
||||
3000
|
||||
);
|
||||
pollCancelRef.current = cancelPoll;
|
||||
} catch (error) {
|
||||
console.error('Submission failed:', error);
|
||||
Alert.alert('错误', '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormFields = () => {
|
||||
if (!template?.formSchema?.startNodes) return null;
|
||||
|
||||
const nodes = template.formSchema.startNodes;
|
||||
const imageNodes = nodes.filter((node: TemplateGraphNode) => node.type === 'image');
|
||||
|
||||
// 当恰好有2个图片字段时,使用横向布局
|
||||
if (imageNodes.length === 2) {
|
||||
const result: React.ReactNode[] = [];
|
||||
let imageIndex = 0;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
if (node.type === 'image') {
|
||||
// 第一个图片字段:开始行容器
|
||||
if (imageIndex === 0) {
|
||||
const firstImage = node;
|
||||
const secondImage = imageNodes[1];
|
||||
|
||||
result.push(
|
||||
<View key={`image-row`} style={styles.imageRow}>
|
||||
<View style={styles.imageColumn}>
|
||||
<DynamicFormField
|
||||
node={firstImage}
|
||||
value={formData[firstImage.id]}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, [firstImage.id]: value }));
|
||||
if (errors[firstImage.id]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[firstImage.id];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
error={errors[firstImage.id]}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.imageColumn}>
|
||||
<DynamicFormField
|
||||
node={secondImage}
|
||||
value={formData[secondImage.id]}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, [secondImage.id]: value }));
|
||||
if (errors[secondImage.id]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[secondImage.id];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
error={errors[secondImage.id]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
imageIndex++;
|
||||
}
|
||||
// 第二个图片字段已在上面处理,跳过
|
||||
imageIndex++;
|
||||
} else {
|
||||
// 非图片字段正常渲染
|
||||
result.push(
|
||||
<DynamicFormField
|
||||
key={node.id}
|
||||
node={node}
|
||||
value={formData[node.id]}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, [node.id]: value }));
|
||||
if (errors[node.id]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[node.id];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
error={errors[node.id]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 默认垂直布局
|
||||
return nodes.map((node: TemplateGraphNode) => (
|
||||
<DynamicFormField
|
||||
key={node.id}
|
||||
node={node}
|
||||
value={formData[node.id]}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, [node.id]: value }));
|
||||
if (errors[node.id]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[node.id];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
error={errors[node.id]}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const renderGenerating = () => {
|
||||
if (!template) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.stepScroll}
|
||||
contentContainerStyle={styles.step1Content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.generatingContainer}>
|
||||
<View style={styles.generatingImageWrapper}>
|
||||
<Image source={{ uri: getVideoThumbnail(template.previewUrl, { width: 144, height: 240 }) }} style={styles.uploadedImage} resizeMode="cover" />
|
||||
</View>
|
||||
|
||||
<Image source={require('@/assets/images/star.png')} style={{ width: 32, height: 32 }} />
|
||||
<Text style={styles.generatingText}>正在加载生成中...</Text>
|
||||
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={styles.progressBarTrack}>
|
||||
<View style={styles.progressBarFill} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep1 = () => {
|
||||
if (!template) return null;
|
||||
const aspectRatio = template.aspectRatio || '3:4';
|
||||
const [w, h] = aspectRatio.split(':');
|
||||
const itemWidth = 320;
|
||||
const height = itemWidth * parseInt(h) / parseInt(w);
|
||||
const posterImage = template.coverImageUrl || template.previewUrl;
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.stepScroll}
|
||||
contentContainerStyle={styles.step1Content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={styles.heroSubtitle}>
|
||||
{template.descriptionEn}
|
||||
</Text>
|
||||
<View style={{ position: 'relative', borderRadius: 16, marginBottom: 8, height: height, width: itemWidth, overflow: 'hidden', margin: `auto` }}>
|
||||
<VideoPlayer source={{ uri: template.previewUrl }} originalImage={posterImage} />
|
||||
</View>
|
||||
<View style={{ height: 8 }}></View>
|
||||
{pollingStatus.isPolling ? renderGenerating() : renderFormFields()}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header title={(template?.titleEn ?? 'AI视频生成').toUpperCase()} />
|
||||
{renderStep1()}
|
||||
<GenerateBtn onGenerate={handleSubmit} loading={isSubmitting || pollingStatus.isPolling} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingCanvas: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
stepScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
step1Content: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
gap: 12,
|
||||
},
|
||||
stepIndicator: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: 13,
|
||||
letterSpacing: 2,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 26,
|
||||
lineHeight: 32,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 16,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: 'rgba(255, 255, 255, 0.68)',
|
||||
marginBottom: 24,
|
||||
},
|
||||
previewStage: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
previewImage: {
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
},
|
||||
previewInset: {
|
||||
position: 'absolute',
|
||||
bottom: 18,
|
||||
left: 18,
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
previewInsetImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
generateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 58,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#D1FF00',
|
||||
gap: 10,
|
||||
},
|
||||
generateButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
color: '#050505',
|
||||
},
|
||||
imageRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
imageColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
generatingTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.8,
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
generatingSubtitle: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.68)',
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
uploadedImageContainer: {
|
||||
marginBottom: 24,
|
||||
margin: 'auto'
|
||||
},
|
||||
generatingContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
backgroundColor: '#232527'
|
||||
},
|
||||
generatingImageWrapper: {
|
||||
position: 'relative',
|
||||
marginBottom: 24,
|
||||
},
|
||||
generatingIconOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
generatingText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 24,
|
||||
},
|
||||
progressBarContainer: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
progressBarTrack: {
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(136, 245, 250, 0.2)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
width: '60%',
|
||||
backgroundColor: '#88F5FA',
|
||||
borderRadius: 2,
|
||||
},
|
||||
uploadedImageLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
marginBottom: 12,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
uploadedImage: {
|
||||
width: 144,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
progressContainer: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 32,
|
||||
},
|
||||
});
|
||||
3
assets/icons/bestai.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
4
assets/icons/content.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.4" d="M14.07 4.26245H13.6175C13.316 4.26453 13.0178 4.19924 12.7448 4.07136C12.4717 3.94348 12.2306 3.75623 12.0392 3.52329L11.2317 2.40662C11.0436 2.17161 10.8043 1.98273 10.5321 1.85447C10.2598 1.72621 9.96178 1.66195 9.66084 1.66662L5.92751 1.66662C2.81501 1.66662 1.66667 3.49329 1.66667 6.59995V9.95662C1.66251 10.3258 18.33 10.3258 18.3308 9.95662V8.98162C18.3458 5.87495 17.2267 4.26245 14.07 4.26245Z" fill="#8B8B8D"/>
|
||||
<path d="M14.0625 4.26249C15.15 4.17832 16.2292 4.50582 17.0858 5.17916C17.185 5.26249 17.2767 5.35416 17.3608 5.45249C17.6275 5.76499 17.8325 6.12332 17.9675 6.51082C18.2333 7.30582 18.3558 8.14166 18.3308 8.97999V13.3575C18.3297 13.7262 18.3024 14.0943 18.2492 14.4592C18.1479 15.1033 17.9214 15.7213 17.5825 16.2783C17.4266 16.5474 17.2373 16.7958 17.0192 17.0175C16.53 17.4663 15.9563 17.8132 15.3317 18.038C14.707 18.2628 14.0438 18.3609 13.3808 18.3267H6.60917C5.94512 18.3608 5.2809 18.2627 4.65505 18.0381C4.0292 17.8135 3.45418 17.4669 2.96334 17.0183C2.7478 16.7959 2.56106 16.5473 2.4075 16.2783C2.07068 15.7217 1.84908 15.1031 1.75584 14.4592C1.69655 14.0952 1.66673 13.7271 1.66667 13.3583V8.98082C1.66667 8.61499 1.68667 8.24999 1.72584 7.88666C1.74834 7.71666 1.80084 7.55332 1.80084 7.39082C1.87584 6.95249 2.01251 6.52666 2.20751 6.12666C2.78584 4.89166 3.97084 4.26332 5.9125 4.26332L14.0625 4.26249ZM14.2625 11.5758H5.80834C5.71521 11.5712 5.62211 11.5856 5.53471 11.6181C5.4473 11.6506 5.36741 11.7005 5.29988 11.7648C5.23234 11.8291 5.17857 11.9064 5.14183 11.9921C5.10509 12.0778 5.08614 12.1701 5.08614 12.2633C5.08614 12.3566 5.10509 12.4488 5.14183 12.5345C5.17857 12.6202 5.23234 12.6976 5.29988 12.7619C5.36741 12.8262 5.4473 12.8761 5.53471 12.9085C5.62211 12.941 5.71521 12.9554 5.80834 12.9508H14.2108C14.3019 12.9547 14.3928 12.9406 14.4783 12.9091C14.5638 12.8777 14.6423 12.8297 14.7092 12.7678C14.776 12.7059 14.83 12.6313 14.8679 12.5485C14.9058 12.4656 14.9269 12.3761 14.93 12.285C14.9403 12.1235 14.8871 11.9644 14.7817 11.8417C14.7219 11.7601 14.6439 11.6935 14.5539 11.6472C14.464 11.6009 14.3645 11.5762 14.2633 11.575L14.2625 11.5758Z" fill="#8B8B8D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
5
assets/icons/home.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M1.94583 6.56246C1.49583 7.50163 1.65417 8.60079 1.97083 10.7983L2.2025 12.4125C2.60833 15.2358 2.81167 16.6466 3.79083 17.49C4.77 18.3333 6.20583 18.3333 9.07833 18.3333H10.9217C13.7942 18.3333 15.23 18.3333 16.2092 17.49C17.1883 16.6466 17.3917 15.2358 17.7975 12.4125L18.03 10.7983C18.3467 8.60079 18.505 7.50163 18.0542 6.56246C17.6033 5.62329 16.645 5.05163 14.7275 3.90996L13.5733 3.22246C11.8333 2.18496 10.9617 1.66663 10 1.66663C9.03833 1.66663 8.1675 2.18496 6.42667 3.22246L5.2725 3.90996C3.35583 5.05163 2.39667 5.62329 1.94583 6.56246ZM10 15.625C9.83424 15.625 9.67527 15.5591 9.55806 15.4419C9.44085 15.3247 9.375 15.1657 9.375 15V12.5C9.375 12.3342 9.44085 12.1752 9.55806 12.058C9.67527 11.9408 9.83424 11.875 10 11.875C10.1658 11.875 10.3247 11.9408 10.4419 12.058C10.5592 12.1752 10.625 12.3342 10.625 12.5V15C10.625 15.1657 10.5592 15.3247 10.4419 15.4419C10.3247 15.5591 10.1658 15.625 10 15.625Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
assets/icons/start.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="24" viewBox="0 0 18 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.1595 7.0437L10.0478 6.20558C10.0208 6.00292 9.84787 5.85156 9.64344 5.85156C9.43893 5.85156 9.26613 6.00292 9.23904 6.20557L9.12736 7.0437C8.7995 9.50215 6.86543 11.4363 4.40698 11.764L3.56885 11.8758C3.36619 11.9028 3.21484 12.0757 3.21484 12.2802C3.21484 12.4846 3.36619 12.6575 3.56885 12.6845L4.40698 12.7962C6.86542 13.124 8.7995 15.0582 9.12736 17.5166L9.23904 18.3547C9.26613 18.5574 9.43893 18.7087 9.64344 18.7087C9.84787 18.7087 10.0208 18.5574 10.0478 18.3547L10.1595 17.5166C10.4873 15.0582 12.4214 13.124 14.8798 12.7962L15.718 12.6845C15.9206 12.6575 16.072 12.4846 16.072 12.2802C16.072 12.0757 15.9206 11.9028 15.718 11.8758L14.8798 11.764C12.4214 11.4363 10.4873 9.50215 10.1595 7.0437Z" fill="#D1FE17"/>
|
||||
<path d="M3.9887 16.076L4.13333 15.6059C4.15414 15.5383 4.21662 15.4922 4.28739 15.4922C4.35815 15.4922 4.42063 15.5383 4.44144 15.6059L4.58607 16.076C4.77157 16.6789 5.24353 17.1508 5.84643 17.3363L6.31646 17.4809C6.38409 17.5018 6.43025 17.5642 6.43025 17.635C6.43025 17.7058 6.38409 17.7682 6.31646 17.7891L5.84644 17.9337C5.24353 18.1192 4.77157 18.5912 4.58607 19.1941L4.44144 19.6641C4.42063 19.7317 4.35815 19.7779 4.28739 19.7779C4.21662 19.7779 4.15414 19.7317 4.13333 19.6641L3.98871 19.1941C3.8032 18.5912 3.33124 18.1192 2.72833 17.9337L2.25831 17.7891C2.19068 17.7682 2.14453 17.7058 2.14453 17.635C2.14453 17.5642 2.19068 17.5018 2.25831 17.4809L2.72834 17.3363C3.33124 17.1508 3.8032 16.6789 3.9887 16.076Z" fill="#D1FE17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
assets/icons/user.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.50001 11.6666C6.95284 11.6666 6.41102 11.7744 5.9055 11.9838C5.39997 12.1932 4.94064 12.5001 4.55373 12.887C4.16682 13.2739 3.85991 13.7333 3.65051 14.2388C3.44112 14.7443 3.33334 15.2861 3.33334 15.8333C3.33334 16.4963 3.59674 17.1322 4.06558 17.6011C4.53442 18.0699 5.1703 18.3333 5.83334 18.3333H14.1667C14.8297 18.3333 15.4656 18.0699 15.9344 17.6011C16.4033 17.1322 16.6667 16.4963 16.6667 15.8333C16.6667 15.2861 16.5589 14.7443 16.3495 14.2388C16.1401 13.7333 15.8332 13.2739 15.4463 12.887C15.0594 12.5001 14.6 12.1932 14.0945 11.9838C13.589 11.7744 13.0472 11.6666 12.5 11.6666H7.50001ZM10 1.66663C9.45284 1.66663 8.91102 1.7744 8.4055 1.98379C7.89997 2.19319 7.44064 2.5001 7.05373 2.88701C6.66682 3.27393 6.35991 3.73325 6.15051 4.23878C5.94112 4.7443 5.83334 5.28612 5.83334 5.83329C5.83334 6.38047 5.94112 6.92228 6.15051 7.42781C6.35991 7.93333 6.66682 8.39266 7.05373 8.77957C7.44064 9.16648 7.89997 9.4734 8.4055 9.68279C8.91102 9.89219 9.45284 9.99996 10 9.99996C11.1051 9.99996 12.1649 9.56097 12.9463 8.77957C13.7277 7.99817 14.1667 6.93836 14.1667 5.83329C14.1667 4.72822 13.7277 3.66842 12.9463 2.88701C12.1649 2.10561 11.1051 1.66663 10 1.66663Z" fill="#8B8B8D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/content.png
Normal file
|
After Width: | Height: | Size: 712 B |
BIN
assets/images/credits.png
Normal file
|
After Width: | Height: | Size: 913 B |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/home.png
Normal file
|
After Width: | Height: | Size: 505 B |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/images/loading.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/images/more.png
Normal file
|
After Width: | Height: | Size: 259 B |
BIN
assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/setting.png
Normal file
|
After Width: | Height: | Size: 802 B |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/star.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/images/start.png
Normal file
|
After Width: | Height: | Size: 797 B |
BIN
assets/images/user.png
Normal file
|
After Width: | Height: | Size: 648 B |
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/global.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
200
components/Button.example.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Text } from 'react-native';
|
||||
import Button from './Button';
|
||||
|
||||
const ButtonExample: React.FC = () => {
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
primary: false,
|
||||
secondary: false,
|
||||
outline: false,
|
||||
text: false,
|
||||
});
|
||||
|
||||
const toggleLoading = (variant: keyof typeof loadingStates) => {
|
||||
setLoadingStates(prev => ({
|
||||
...prev,
|
||||
[variant]: !prev[variant],
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePress = (variant: string) => {
|
||||
// Button pressed
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Primary Button"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('primary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Secondary Button"
|
||||
variant="secondary"
|
||||
onPress={() => handlePress('secondary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Outline Button"
|
||||
variant="outline"
|
||||
onPress={() => handlePress('outline')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Text Button"
|
||||
variant="text"
|
||||
onPress={() => handlePress('text')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Small Button"
|
||||
size="small"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('small')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Medium Button"
|
||||
size="medium"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('medium')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Large Button"
|
||||
size="large"
|
||||
variant="primary"
|
||||
onPress={() => handlePress('large')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Full Width Button"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onPress={() => handlePress('fullWidth')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Disabled Button"
|
||||
variant="primary"
|
||||
disabled
|
||||
onPress={() => handlePress('disabled')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Primary Loading"
|
||||
variant="primary"
|
||||
loading={loadingStates.primary}
|
||||
onPress={() => toggleLoading('primary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Secondary Loading"
|
||||
variant="secondary"
|
||||
loading={loadingStates.secondary}
|
||||
onPress={() => toggleLoading('secondary')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Outline Loading"
|
||||
variant="outline"
|
||||
loading={loadingStates.outline}
|
||||
onPress={() => toggleLoading('outline')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Text Loading"
|
||||
variant="text"
|
||||
loading={loadingStates.text}
|
||||
onPress={() => toggleLoading('text')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Button with Icon"
|
||||
variant="primary"
|
||||
icon={
|
||||
<View style={styles.icon}>
|
||||
<Text style={styles.iconText}>⚡</Text>
|
||||
</View>
|
||||
}
|
||||
onPress={() => handlePress('withIcon')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="Large Outline with Icon"
|
||||
variant="outline"
|
||||
size="large"
|
||||
icon={
|
||||
<View style={styles.icon}>
|
||||
<Text style={[styles.iconText, { color: '#007AFF' }]}>🚀</Text>
|
||||
</View>
|
||||
}
|
||||
onPress={() => handlePress('largeOutlineWithIcon')}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E0E0E0',
|
||||
marginVertical: 24,
|
||||
},
|
||||
icon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonExample;
|
||||
155
components/Button.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
} from 'react-native';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Animation } from '@/constants/theme';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text';
|
||||
type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: Colors.brand.primary,
|
||||
borderWidth: 0,
|
||||
borderColor: 'transparent',
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: Colors.brand.secondary,
|
||||
borderWidth: 0,
|
||||
borderColor: 'transparent',
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.brand.primary,
|
||||
textColor: Colors.brand.primary,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
borderColor: 'transparent',
|
||||
textColor: Colors.brand.primary,
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
small: {
|
||||
height: 36,
|
||||
paddingHorizontal: Spacing.md,
|
||||
fontSize: FontSize.sm,
|
||||
},
|
||||
medium: {
|
||||
height: 48,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
fontSize: FontSize.md,
|
||||
},
|
||||
large: {
|
||||
height: 56,
|
||||
paddingHorizontal: Spacing.xxl,
|
||||
fontSize: FontSize.lg,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
icon,
|
||||
style,
|
||||
}) => {
|
||||
const variantConfig = config.variants[variant];
|
||||
const sizeConfig = config.sizes[size];
|
||||
const isInteractive = !disabled && !loading;
|
||||
|
||||
const buttonStyle = [
|
||||
styles.base,
|
||||
{
|
||||
height: sizeConfig.height,
|
||||
paddingHorizontal: sizeConfig.paddingHorizontal,
|
||||
},
|
||||
{
|
||||
backgroundColor: variantConfig.backgroundColor,
|
||||
borderWidth: variantConfig.borderWidth,
|
||||
borderColor: variantConfig.borderColor,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
fullWidth && styles.fullWidth,
|
||||
style,
|
||||
];
|
||||
|
||||
const textStyle = [
|
||||
styles.text,
|
||||
{
|
||||
color: variantConfig.textColor,
|
||||
fontSize: sizeConfig.fontSize,
|
||||
},
|
||||
];
|
||||
|
||||
const indicatorColor = variant === 'outline' || variant === 'text'
|
||||
? '#007AFF'
|
||||
: '#FFFFFF';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={!isInteractive}
|
||||
style={({ pressed }) => [
|
||||
buttonStyle,
|
||||
isInteractive && pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<>{icon}</>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={indicatorColor} />
|
||||
) : (
|
||||
<Text style={textStyle}>{title}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: BorderRadius.full,
|
||||
fontWeight: '600',
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
pressed: {
|
||||
transform: [{ scale: Animation.scale.pressed }],
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default Button;
|
||||
63
components/CategoryTabs.example.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ExampleScreen: React.FC = () => {
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string>('all');
|
||||
|
||||
const categories: Category[] = [
|
||||
{ id: 'all', name: '全部' },
|
||||
{ id: 'finance', name: '财经' },
|
||||
{ id: 'entertainment', name: '娱乐' },
|
||||
{ id: 'education', name: '教育' },
|
||||
{ id: 'technology', name: '科技' },
|
||||
{ id: 'sports', name: '体育' },
|
||||
{ id: 'lifestyle', name: '生活' },
|
||||
{ id: 'travel', name: '旅行' },
|
||||
{ id: 'health', name: '健康' },
|
||||
{ id: 'food', name: '美食' },
|
||||
];
|
||||
|
||||
const handleCategoryChange = (category: Category) => {
|
||||
setActiveCategoryId(category.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CategoryTabs
|
||||
categories={categories}
|
||||
activeId={activeCategoryId}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.contentText}>
|
||||
当前选中: {categories.find(cat => cat.id === activeCategoryId)?.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentText: {
|
||||
fontSize: 16,
|
||||
color: '#333333',
|
||||
},
|
||||
});
|
||||
|
||||
export default ExampleScreen;
|
||||
162
components/CategoryTabs.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
useDerivedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { Colors, Spacing, FontSize, Animation } from '@/constants/theme';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CategoryTabsProps {
|
||||
categories?: Category[];
|
||||
activeId?: string;
|
||||
onChange?: (category: Category) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORIES: Category[] = [
|
||||
{ id: 'all', name: '全部' },
|
||||
{ id: 'finance', name: '财经' },
|
||||
{ id: 'entertainment', name: '娱乐' },
|
||||
{ id: 'education', name: '教育' },
|
||||
{ id: 'technology', name: '科技' },
|
||||
{ id: 'sports', name: '体育' },
|
||||
{ id: 'lifestyle', name: '生活' },
|
||||
{ id: 'travel', name: '旅行' },
|
||||
];
|
||||
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories = DEFAULT_CATEGORIES,
|
||||
activeId = 'all',
|
||||
onChange,
|
||||
style,
|
||||
}) => {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const activeIndex = useMemo(() => {
|
||||
return categories.findIndex(cat => cat.id === activeId);
|
||||
}, [categories, activeId]);
|
||||
|
||||
const indicatorPosition = useDerivedValue(() => {
|
||||
if (categories.length === 0) return 0;
|
||||
|
||||
const activeIndexSafe = activeIndex >= 0 ? activeIndex : 0;
|
||||
const padding = 16;
|
||||
const gap = 8;
|
||||
let offset = padding;
|
||||
|
||||
for (let i = 0; i < activeIndexSafe; i++) {
|
||||
const item = categories[i];
|
||||
const itemWidth = Math.min(
|
||||
Math.max(44, item.name.length * 16 + 32),
|
||||
screenWidth / 3
|
||||
);
|
||||
offset += itemWidth + gap;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}, [activeIndex, categories, screenWidth]);
|
||||
|
||||
const activeCategory = categories[activeIndex] || categories[0];
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => {
|
||||
if (!activeCategory) {
|
||||
return { width: 0, left: 0 };
|
||||
}
|
||||
|
||||
const width = Math.min(
|
||||
Math.max(44, activeCategory.name.length * 16 + 32),
|
||||
screenWidth / 3
|
||||
);
|
||||
|
||||
return {
|
||||
width: withTiming(width, { duration: Animation.duration.normal }),
|
||||
left: withTiming(indicatorPosition.value, { duration: Animation.duration.normal }),
|
||||
};
|
||||
});
|
||||
|
||||
const renderCategory = (category: Category, index: number) => {
|
||||
const isActive = category.id === activeId;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={styles.tabItem}
|
||||
onPress={() => onChange?.(category)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, isActive && styles.activeTabText]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={1}
|
||||
>
|
||||
{categories.map(renderCategory)}
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View style={[styles.indicator, indicatorStyle]} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: Colors.background.secondary,
|
||||
width: '100%',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
tabItem: {
|
||||
minWidth: 44,
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
color: Colors.brand.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
backgroundColor: Colors.brand.primary,
|
||||
borderRadius: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(CategoryTabs);
|
||||
104
components/CommonHeader.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { router } from 'expo-router';
|
||||
import { Colors, Spacing, FontSize, Layout } from '@/constants/theme';
|
||||
|
||||
interface CommonHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
rightContent?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export const CommonHeader: React.FC<CommonHeaderProps> = ({
|
||||
title,
|
||||
showBack = true,
|
||||
onBack,
|
||||
rightContent,
|
||||
backgroundColor = '#FFFFFF',
|
||||
}) => {
|
||||
const handleBackPress = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.leftArea}>
|
||||
{showBack && (
|
||||
<TouchableOpacity
|
||||
onPress={handleBackPress}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ArrowLeft size={24} color="#007AFF" strokeWidth={2.5} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.centerArea}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightArea}>
|
||||
{rightContent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: Layout.headerHeight,
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: Colors.border,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.md,
|
||||
},
|
||||
leftArea: {
|
||||
width: 40,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerArea: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.lg,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
rightArea: {
|
||||
width: 40,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
});
|
||||
|
||||
export default CommonHeader;
|
||||
105
components/ErrorRetry.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, StyleProp, ViewStyle } from 'react-native';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
interface ErrorRetryProps {
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
subMessage?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
showIcon?: boolean;
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
export function ErrorRetry({
|
||||
message,
|
||||
onRetry,
|
||||
subMessage,
|
||||
style,
|
||||
showIcon = true,
|
||||
buttonText = '重试',
|
||||
}: ErrorRetryProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.content}>
|
||||
{showIcon && (
|
||||
<View style={styles.iconContainer}>
|
||||
<AlertCircle size={48} color={Colors.brand.danger} strokeWidth={1.5} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.title}>{message}</Text>
|
||||
|
||||
{subMessage && <Text style={styles.subtitle}>{subMessage}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onRetry}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<RefreshCw size={18} color={Colors.brand.primary} style={styles.buttonIcon} />
|
||||
<Text style={styles.buttonText}>{buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: Colors.background.secondary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: Spacing.xl,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: Colors.background.tertiary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.lg,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: Colors.background.tertiary,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.full,
|
||||
...Shadow.small,
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
color: Colors.brand.primary,
|
||||
},
|
||||
});
|
||||
|
||||
export default ErrorRetry;
|
||||
46
components/ErrorView.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface ErrorViewProps {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const ErrorView: React.FC<ErrorViewProps> = ({ message, onRetry }) => (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.errorText}>{message}</Text>
|
||||
{onRetry && (
|
||||
<TouchableOpacity style={styles.retryButton} onPress={onRetry} activeOpacity={0.8}>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: '#EF5350',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#1A1A1A',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#EF5350',
|
||||
},
|
||||
retryText: {
|
||||
fontSize: 14,
|
||||
color: '#EF5350',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
130
components/GiftCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { View, StyleSheet, StyleProp, type ViewStyle } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
export interface GiftCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
points: number;
|
||||
onExchange?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function GiftCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
points,
|
||||
onExchange,
|
||||
style,
|
||||
}: GiftCardProps) {
|
||||
const cardBackgroundColor = useThemeColor(
|
||||
{ light: '#fff', dark: '#1F2223' },
|
||||
'card'
|
||||
);
|
||||
const borderColor = useThemeColor(
|
||||
{ light: '#e0e0e0', dark: '#2C2E2F' },
|
||||
'cardBorder'
|
||||
);
|
||||
const pointsColor = '#FF6B6B';
|
||||
const descriptionColor = useThemeColor(
|
||||
{ light: '#666666', dark: '#9BA1A6' },
|
||||
'text'
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedView
|
||||
style={[
|
||||
styles.card,
|
||||
{ backgroundColor: cardBackgroundColor, borderColor },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<ThemedText style={styles.icon}>{icon}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<ThemedText style={[styles.description, { color: descriptionColor }]}>
|
||||
{description}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.pointsContainer}>
|
||||
<ThemedText style={[styles.points, { color: pointsColor }]}>
|
||||
{points.toLocaleString()}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.pointsLabel, { color: descriptionColor }]}>
|
||||
积分
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Button variant="primary" size="md" onPress={onExchange}>
|
||||
兑换
|
||||
</Button>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: BorderRadius.md,
|
||||
padding: Spacing.md,
|
||||
borderWidth: 1,
|
||||
...Shadow.small,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: Colors.background.tertiary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
description: {
|
||||
fontSize: FontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pointsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
points: {
|
||||
fontSize: FontSize.lg,
|
||||
fontWeight: '700',
|
||||
marginRight: Spacing.xs,
|
||||
},
|
||||
pointsLabel: {
|
||||
fontSize: FontSize.xs,
|
||||
},
|
||||
});
|
||||
49
components/HistoryCard.example.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
import { HistoryCard } from './HistoryCard';
|
||||
|
||||
export function HistoryCardExample() {
|
||||
const mockData = [
|
||||
{
|
||||
templateName: 'AI视频生成模板',
|
||||
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
preview: '生成了一个关于科技产品的宣传视频...',
|
||||
status: 'completed' as const,
|
||||
},
|
||||
{
|
||||
templateName: '图片处理模板',
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'running' as const,
|
||||
},
|
||||
{
|
||||
templateName: '文本摘要模板',
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
|
||||
preview: '对长篇文章进行了智能摘要,提取了核心观点和关键信息...',
|
||||
status: 'failed' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const handleView = (index: number) => {
|
||||
// View details
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
// Delete item
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{mockData.map((item, index) => (
|
||||
<HistoryCard
|
||||
key={index}
|
||||
templateName={item.templateName}
|
||||
createdAt={item.createdAt}
|
||||
preview={item.preview}
|
||||
status={item.status}
|
||||
onView={() => handleView(index)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
200
components/HistoryCard.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
type HistoryStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
interface HistoryCardProps {
|
||||
templateName: string;
|
||||
createdAt: string;
|
||||
preview?: string;
|
||||
status: HistoryStatus;
|
||||
onView?: () => void;
|
||||
onDelete?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function HistoryCard({
|
||||
templateName,
|
||||
createdAt,
|
||||
preview,
|
||||
status,
|
||||
onView,
|
||||
onDelete,
|
||||
style,
|
||||
}: HistoryCardProps) {
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const now = new Date();
|
||||
const past = new Date(dateString);
|
||||
const diffInMinutes = Math.floor((now.getTime() - past.getTime()) / 60000);
|
||||
|
||||
if (diffInMinutes < 1) return 'Just now';
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`;
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `${diffInHours} hours ago`;
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 30) return `${diffInDays} days ago`;
|
||||
|
||||
return past.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: HistoryStatus) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
color: Colors.brand.secondary,
|
||||
text: '进行中',
|
||||
bgColor: '#E8F8F7',
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
color: Colors.brand.success,
|
||||
text: '已完成',
|
||||
bgColor: '#E8F5E9',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
color: Colors.brand.danger,
|
||||
text: '失败',
|
||||
bgColor: '#FFEBEB',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.container, style]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.templateName}>{templateName}</Text>
|
||||
<Text style={styles.timeText}>{formatTimeAgo(createdAt)}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{preview ? (
|
||||
<Text style={styles.previewText} numberOfLines={2}>
|
||||
{preview}
|
||||
</Text>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: statusConfig.bgColor },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.statusText, { color: statusConfig.color }]}>
|
||||
{statusConfig.text}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.viewButton}
|
||||
onPress={onView}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.viewButtonText}>查看详情</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={onDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.deleteButtonText}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: Colors.background.secondary,
|
||||
borderRadius: BorderRadius.md,
|
||||
padding: Spacing.md,
|
||||
marginVertical: Spacing.sm,
|
||||
marginHorizontal: Spacing.md,
|
||||
...Shadow.small,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
templateName: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
flex: 1,
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: FontSize.xs,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'right',
|
||||
},
|
||||
content: {
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
previewText: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.secondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: BorderRadius.full,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
viewButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
borderRadius: BorderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.brand.primary,
|
||||
alignItems: 'center',
|
||||
},
|
||||
viewButtonText: {
|
||||
color: Colors.brand.primary,
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
deleteButton: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: Colors.brand.danger,
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
48
components/LoadingState.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ActivityIndicator, View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
|
||||
import { Colors, Spacing, FontSize } from '@/constants/theme';
|
||||
|
||||
export interface LoadingStateProps {
|
||||
text?: string;
|
||||
subText?: string;
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
text = '加载中...',
|
||||
subText,
|
||||
size = 'large',
|
||||
color = Colors.brand.primary,
|
||||
style,
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ActivityIndicator size={size} color={color} />
|
||||
{text && <Text style={styles.text}>{text}</Text>}
|
||||
{subText && <Text style={styles.subText}>{subText}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.background.secondary,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
},
|
||||
text: {
|
||||
marginTop: Spacing.lg,
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subText: {
|
||||
marginTop: Spacing.sm,
|
||||
fontSize: FontSize.xs,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
137
components/ProgressIndicator.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
currentStep: number;
|
||||
totalSteps?: number;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({
|
||||
currentStep,
|
||||
totalSteps = 2,
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.progressBar}>
|
||||
{Array.from({ length: totalSteps - 1 }, (_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.connector,
|
||||
currentStep > index + 1 && styles.connectorActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.stepsContainer}>
|
||||
{steps.map((step, index) => (
|
||||
<View key={index} style={styles.stepItem}>
|
||||
<View
|
||||
style={[
|
||||
styles.stepCircle,
|
||||
currentStep > index + 1 && styles.stepCircleCompleted,
|
||||
currentStep === index + 1 && styles.stepCircleActive,
|
||||
]}
|
||||
>
|
||||
{currentStep > index + 1 ? (
|
||||
<Text style={styles.checkMark}>✓</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.stepNumber,
|
||||
currentStep >= index + 1 && styles.stepNumberActive,
|
||||
]}
|
||||
>
|
||||
{index + 1}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.stepLabel,
|
||||
currentStep >= index + 1 && styles.stepLabelActive,
|
||||
]}
|
||||
>
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
progressBar: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 32,
|
||||
},
|
||||
connector: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
backgroundColor: '#E5E5EA',
|
||||
marginHorizontal: 8,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
connectorActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
stepsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
stepItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
stepCircle: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F2F2F7',
|
||||
borderWidth: 2,
|
||||
borderColor: '#E5E5EA',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
stepCircleActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
stepCircleCompleted: {
|
||||
backgroundColor: '#4ECDC4',
|
||||
borderColor: '#4ECDC4',
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#8E8E93',
|
||||
},
|
||||
stepNumberActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
checkMark: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8E8E93',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stepLabelActive: {
|
||||
color: '#007AFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default ProgressIndicator;
|
||||
110
components/auth/auth-guard.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
showLoginPrompt?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证守卫组件
|
||||
* 用于包装需要登录才能访问的内容
|
||||
*
|
||||
* @param children - 需要保护的内容
|
||||
* @param fallback - 未登录时显示的自定义内容(可选)
|
||||
* @param showLoginPrompt - 是否显示默认的登录提示(默认true)
|
||||
* @param title - 自定义提示标题
|
||||
* @param subtitle - 自定义提示副标题
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* <AuthGuard>
|
||||
* <ProtectedComponent />
|
||||
* </AuthGuard>
|
||||
*
|
||||
* // 自定义提示
|
||||
* <AuthGuard
|
||||
* title="需要登录"
|
||||
* subtitle="登录后即可使用此功能"
|
||||
* >
|
||||
* <ProtectedComponent />
|
||||
* </AuthGuard>
|
||||
* ```
|
||||
*/
|
||||
export function AuthGuard({
|
||||
children,
|
||||
fallback,
|
||||
showLoginPrompt = true,
|
||||
title = '需要登录',
|
||||
subtitle = '请先登录以继续使用',
|
||||
}: AuthGuardProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <View style={styles.loadingContainer} />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
if (showLoginPrompt) {
|
||||
return (
|
||||
<View style={styles.promptContainer}>
|
||||
<View style={styles.iconContainer}>
|
||||
<ThemedText style={styles.lockIcon}>🔒</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>{subtitle}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
promptContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
lockIcon: {
|
||||
fontSize: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
200
components/auth/auth-prompt.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
|
||||
interface AuthPromptProps {
|
||||
variant?: 'card' | 'fullscreen' | 'inline';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actionLabel?: string;
|
||||
showIcon?: boolean;
|
||||
iconName?: keyof typeof Ionicons.glyphMap;
|
||||
gradientColors?: [string, string, ...string[]];
|
||||
onLoginPress?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证提示组件
|
||||
* 为未登录用户提供清晰的登录引导
|
||||
*/
|
||||
export function AuthPrompt({
|
||||
variant = 'card',
|
||||
title = '需要登录',
|
||||
subtitle = '登录后即可使用所有功能',
|
||||
actionLabel = '立即登录',
|
||||
showIcon = true,
|
||||
iconName = 'log-in-outline',
|
||||
gradientColors = ['#007AFF', '#0056D2'] as [string, string],
|
||||
onLoginPress,
|
||||
}: AuthPromptProps) {
|
||||
const { requireAuth } = useAuthGuard();
|
||||
|
||||
const handleLogin = () => {
|
||||
if (onLoginPress) {
|
||||
onLoginPress();
|
||||
} else {
|
||||
requireAuth();
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === 'fullscreen') {
|
||||
return (
|
||||
<View style={styles.fullscreenContainer}>
|
||||
<View style={styles.fullscreenContent}>
|
||||
{showIcon && (
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name={iconName} size={32} color="#007AFF" />
|
||||
</View>
|
||||
)}
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>{subtitle}</ThemedText>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleLogin}>
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.buttonGradient}
|
||||
>
|
||||
<Ionicons name={iconName} size={20} color="#fff" style={styles.buttonIcon} />
|
||||
<ThemedText style={styles.buttonText}>{actionLabel}</ThemedText>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<View style={styles.cardContainer}>
|
||||
{showIcon && (
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name={iconName} size={32} color="#007AFF" />
|
||||
</View>
|
||||
)}
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>{subtitle}</ThemedText>
|
||||
<TouchableOpacity style={styles.cardButton} onPress={handleLogin}>
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.buttonGradient}
|
||||
>
|
||||
<Ionicons name={iconName} size={16} color="#fff" style={styles.buttonIcon} />
|
||||
<ThemedText style={styles.buttonText}>{actionLabel}</ThemedText>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.inlineContainer}>
|
||||
<ThemedText style={styles.inlineTitle}>{title}</ThemedText>
|
||||
<TouchableOpacity style={styles.inlineButton} onPress={handleLogin}>
|
||||
<ThemedText style={styles.inlineButtonText}>{actionLabel}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fullscreenContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
fullscreenContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: 48,
|
||||
},
|
||||
actionButton: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
elevation: 8,
|
||||
},
|
||||
cardContainer: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
margin: 20,
|
||||
},
|
||||
cardButton: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
buttonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
inlineContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
inlineTitle: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#ffffff',
|
||||
marginRight: 12,
|
||||
},
|
||||
inlineButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 8,
|
||||
},
|
||||
inlineButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
93
components/auth/auth-provider.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { authEvents } from '@/lib/api/client';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { LoginModal } from './login-modal';
|
||||
|
||||
export interface AuthContextType {
|
||||
requireAuth: (callback?: () => void) => boolean;
|
||||
showLoginModal: boolean;
|
||||
setShowLoginModal: (show: boolean) => void;
|
||||
pendingCallback: (() => void) | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export { AuthContext };
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [pendingCallback, setPendingCallback] = useState<(() => void) | null>(null);
|
||||
|
||||
// 认证拦截函数
|
||||
const requireAuth = useCallback((callback?: () => void): boolean => {
|
||||
if (isLoading) {
|
||||
// 如果认证状态还在加载中,不执行任何操作
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// 未登录,保存回调并显示登录Modal
|
||||
setPendingCallback(() => callback || null);
|
||||
setShowLoginModal(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已登录,执行回调
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
return true;
|
||||
}, [isAuthenticated, isLoading]);
|
||||
|
||||
// 监听登录状态变化
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && showLoginModal) {
|
||||
// 登录成功,自动关闭Modal并执行回调
|
||||
if (pendingCallback) {
|
||||
pendingCallback();
|
||||
setPendingCallback(null);
|
||||
}
|
||||
setShowLoginModal(false);
|
||||
}
|
||||
}, [isAuthenticated, showLoginModal, pendingCallback]);
|
||||
|
||||
// 监听 401 未授权事件
|
||||
useEffect(() => {
|
||||
const unsubscribe = authEvents.onUnauthorized(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [isLoading, isAuthenticated]);
|
||||
|
||||
const value = {
|
||||
requireAuth,
|
||||
showLoginModal,
|
||||
setShowLoginModal,
|
||||
pendingCallback,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
<LoginModal
|
||||
visible={showLoginModal}
|
||||
onClose={() => {
|
||||
setShowLoginModal(false);
|
||||
setPendingCallback(null);
|
||||
}}
|
||||
/>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthGuard() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuthGuard must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
117
components/auth/debug-session.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { storage } from "@/lib/storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export function DebugSession() {
|
||||
const [sessionToken, setSessionToken] = useState<string | null>(null);
|
||||
|
||||
const loadSessionToken = async () => {
|
||||
try {
|
||||
const token = await storage.getItem("bestaibest.better-auth.session_token");
|
||||
setSessionToken(token);
|
||||
} catch (error) {
|
||||
console.error("Error loading session token:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSessionToken();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadSessionToken();
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
Alert.alert("清除会话", "确定要清除 SecureStore 中的会话数据吗?", [
|
||||
{ text: "取消", style: "cancel" },
|
||||
{
|
||||
text: "确定",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
storage.removeItem("bestaibest.better-auth.session_token");
|
||||
|
||||
setTimeout(() => {
|
||||
loadSessionToken();
|
||||
}, 100);
|
||||
|
||||
Alert.alert("成功", "会话已清除");
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="subtitle" style={styles.title}>
|
||||
调试信息
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<ThemedText style={styles.label}>Session Token:</ThemedText>
|
||||
<ThemedText style={styles.value} numberOfLines={1}>
|
||||
{sessionToken ? `${sessionToken.slice(0, 20)}...` : "无"}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.button} onPress={handleRefresh}>
|
||||
<ThemedText style={styles.buttonText}>刷新</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.dangerButton]}
|
||||
onPress={handleClear}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>清除会话</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
marginVertical: 16,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
label: {
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
backgroundColor: "#007AFF",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: "#FF3B30",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
526
components/auth/login-modal.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Animated,
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
interface LoginModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ visible, onClose }: LoginModalProps) {
|
||||
const { refetch } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPasswordVisible, setPasswordVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Web端CSS样式 - 覆盖autofill + 禁用密码自动填充
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
const cssStyles = `
|
||||
/* 输入框基础样式 */
|
||||
.login-input-field,
|
||||
.login-input-field input,
|
||||
.login-input-field textarea {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
}
|
||||
|
||||
/* 自动填充状态覆盖 */
|
||||
.login-input-field:-webkit-autofill,
|
||||
.login-input-field:-webkit-autofill:hover,
|
||||
.login-input-field:-webkit-autofill:focus,
|
||||
.login-input-field:-webkit-autofill:active {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
-webkit-text-fill-color: #f5f6f8 !important;
|
||||
caret-color: #f5f6f8 !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.login-input-field input:-webkit-autofill,
|
||||
.login-input-field input:-webkit-autofill:hover,
|
||||
.login-input-field input:-webkit-autofill:focus,
|
||||
.login-input-field input:-webkit-autofill:active,
|
||||
.login-input-field textarea:-webkit-autofill,
|
||||
.login-input-field textarea:-webkit-autofill:hover,
|
||||
.login-input-field textarea:-webkit-autofill:focus,
|
||||
.login-input-field textarea:-webkit-autofill:active {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
-webkit-text-fill-color: #f5f6f8 !important;
|
||||
caret-color: #f5f6f8 !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Chrome内部autofill状态 */
|
||||
.login-input-field:-internal-autofill-selected,
|
||||
.login-input-field:-internal-autofill-previewed,
|
||||
.login-input-field input:-internal-autofill-selected,
|
||||
.login-input-field input:-internal-autofill-previewed,
|
||||
.login-input-field textarea:-internal-autofill-selected,
|
||||
.login-input-field textarea:-internal-autofill-previewed {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
-webkit-text-fill-color: #f5f6f8 !important;
|
||||
caret-color: #f5f6f8 !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* 文本选择高亮 */
|
||||
.login-input-field::selection {
|
||||
background-color: #f6474d;
|
||||
}
|
||||
|
||||
/* 密码输入框禁用自动填充 */
|
||||
input[type="password"][class*="login-input-field"] {
|
||||
-webkit-text-security: disc !important;
|
||||
}
|
||||
|
||||
/* 隐藏自动填充样式覆盖提示 */
|
||||
input[class*="login-input-field"]:-webkit-autofill,
|
||||
input[class*="login-input-field"]:-webkit-autofill:hover,
|
||||
input[class*="login-input-field"]:-webkit-autofill:focus,
|
||||
input[class*="login-input-field"]:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// 创建style标签
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = cssStyles;
|
||||
style.id = 'login-autofill-styles';
|
||||
|
||||
// 添加到head
|
||||
const existingStyle = document.getElementById('login-autofill-styles');
|
||||
if (!existingStyle) {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
const element = document.getElementById('login-autofill-styles');
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.5,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: Dimensions.get("window").height,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && visible) {
|
||||
onClose();
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
}
|
||||
}, [isAuthenticated, visible, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: Dimensions.get("window").height,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => onClose());
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password.trim()) {
|
||||
Alert.alert("错误", "请输入邮箱和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await new Promise<void>(async (resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('登录超时,请检查网络连接'));
|
||||
}, 60000);
|
||||
|
||||
await authClient.signIn.username(
|
||||
{
|
||||
username: email.trim(),
|
||||
password,
|
||||
},
|
||||
{
|
||||
onSuccess: async (ctx) => {
|
||||
clearTimeout(timer);
|
||||
const authToken = ctx.response.headers.get("set-auth-token");
|
||||
if (authToken) {
|
||||
await storage.setItem(
|
||||
`bestaibest.better-auth.session_token`,
|
||||
authToken
|
||||
);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('登录失败:未收到认证令牌'));
|
||||
}
|
||||
},
|
||||
onError: (ctx) => {
|
||||
console.error(`[LOGIN] login error`, ctx)
|
||||
clearTimeout(timer);
|
||||
reject(new Error(ctx.error?.message || '登录失败'));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await refetch();
|
||||
} catch (error: any) {
|
||||
Alert.alert("登录失败", error?.message || "邮箱或密码错误");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropPress = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// Web端CSS类名 - 仅使用CSS类名,避开类型限制
|
||||
const webInputProps = Platform.select({
|
||||
web: {
|
||||
className: 'login-input-field',
|
||||
},
|
||||
default: {},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={styles.backdrop}
|
||||
onPress={handleBackdropPress}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.5)"]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.modalContent}>
|
||||
<View style={styles.sheetIndicator} />
|
||||
|
||||
<SafeAreaView>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleGroup}>
|
||||
<View style={styles.iconBadge}>
|
||||
<Ionicons name="mail-open" size={16} color="#ffffff" />
|
||||
</View>
|
||||
<Text style={styles.title}>Login</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleClose}
|
||||
style={styles.closeButton}
|
||||
>
|
||||
<Feather name="x" size={20} color="#f2f4f8" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={[styles.input, Platform.select({ web: { outline: 'none' } as any })]}
|
||||
placeholder="yours@example.com"
|
||||
placeholderTextColor="#70737c"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
editable={!isLoading}
|
||||
keyboardAppearance="dark"
|
||||
{...webInputProps}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={[styles.input, Platform.select({ web: { outline: 'none' } as any })]}
|
||||
placeholder="email password"
|
||||
placeholderTextColor="#70737c"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
keyboardAppearance="dark"
|
||||
// 密码框专用属性
|
||||
textContentType={Platform.select({ web: undefined, default: "newPassword" })}
|
||||
{...webInputProps}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => setPasswordVisible((visible) => !visible)}
|
||||
style={styles.visibilityToggle}
|
||||
>
|
||||
<Feather
|
||||
name={isPasswordVisible ? "eye-off" : "eye"}
|
||||
size={18}
|
||||
color="#8a8e97"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.loginButton, isLoading && styles.loginButtonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#0f100f" />
|
||||
) : (
|
||||
<Text style={styles.loginButtonText}>Log in</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.termsRow}>
|
||||
<Ionicons name="checkmark-circle" size={16} color="#d7ff1f" />
|
||||
<Text style={styles.termsText}>
|
||||
已阅读并同意
|
||||
<Text style={styles.linkText}> 用户协议 </Text>
|
||||
和
|
||||
<Text style={styles.linkText}> 隐私政策</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
},
|
||||
modalContainer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: "#14161c",
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
shadowRadius: 18,
|
||||
shadowOpacity: 0.25,
|
||||
elevation: 24,
|
||||
},
|
||||
sheetIndicator: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
||||
borderRadius: 2,
|
||||
alignSelf: "center",
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 34,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 16,
|
||||
marginBottom: 28,
|
||||
},
|
||||
titleGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconBadge: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f6474d",
|
||||
marginRight: 12,
|
||||
},
|
||||
title: {
|
||||
color: "#f5f6f8",
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
closeButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.06)",
|
||||
},
|
||||
form: {
|
||||
marginTop: 4,
|
||||
},
|
||||
inputWrapper: {
|
||||
position: "relative",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 18,
|
||||
backgroundColor: "#1a1d23",
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.05)",
|
||||
marginBottom: 18,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingVertical: 16,
|
||||
fontSize: 16,
|
||||
color: "#f5f6f8",
|
||||
backgroundColor: "transparent",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
visibilityToggle: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
loginButton: {
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#d7ff1f",
|
||||
shadowColor: "#d7ff1f",
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
loginButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: "#10120d",
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
termsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 18,
|
||||
},
|
||||
termsText: {
|
||||
color: "#a7abb5",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginLeft: 8,
|
||||
},
|
||||
linkText: {
|
||||
color: "#f5f6f8",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
138
components/bestai/category-tabs.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||
|
||||
type Category = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type CategoryTabsProps = {
|
||||
categories: Category[];
|
||||
activeId: string;
|
||||
onChange: (id: string) => void;
|
||||
};
|
||||
|
||||
export function CategoryTabs({ categories, activeId, onChange }: CategoryTabsProps) {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const tabPositionsRef = useRef<Map<string, { x: number; width: number }>>(new Map());
|
||||
const containerWidthRef = useRef<number>(0);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = tabPositionsRef.current.get(activeId);
|
||||
if (!position || !scrollViewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centerOffset = screenWidth / 2;
|
||||
const tabCenter = position.x + position.width / 2;
|
||||
const scrollX = tabCenter - centerOffset;
|
||||
|
||||
const maxScrollX = Math.max(0, containerWidthRef.current - screenWidth);
|
||||
const targetX = Math.max(0, Math.min(scrollX, maxScrollX));
|
||||
|
||||
scrollViewRef.current.scrollTo({
|
||||
x: targetX,
|
||||
animated: true,
|
||||
});
|
||||
}, [activeId, screenWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(scrollToActiveTab, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scrollToActiveTab]);
|
||||
|
||||
const handleContentSizeChange = useCallback((width: number) => {
|
||||
containerWidthRef.current = width;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.container}
|
||||
style={styles.scrollView}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<CategoryTabItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
isActive={category.id === activeId}
|
||||
onPress={onChange}
|
||||
onLayout={(layout) => {
|
||||
tabPositionsRef.current.set(category.id, {
|
||||
x: layout.x,
|
||||
width: layout.width,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryTabItemProps = {
|
||||
category: Category;
|
||||
isActive: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onLayout: (layout: { x: number; width: number }) => void;
|
||||
};
|
||||
|
||||
const CategoryTabItem = memo(({ category, isActive, onPress, onLayout }: CategoryTabItemProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress(category.id)}
|
||||
style={styles.tabButton}
|
||||
onLayout={(event) => {
|
||||
const { x, width } = event.nativeEvent.layout;
|
||||
onLayout({ x, width });
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.label, isActive && styles.labelActive]}>{category.label}</Text>
|
||||
<View style={[styles.indicator, isActive && styles.indicatorActive]} />
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
CategoryTabItem.displayName = 'CategoryTabItem';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
container: {
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 10
|
||||
},
|
||||
tabButton: {
|
||||
alignItems: 'center',
|
||||
marginRight: 20,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
label: {
|
||||
color: '#7F8794',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
labelActive: {
|
||||
color: '#C7FF00',
|
||||
},
|
||||
indicator: {
|
||||
alignSelf: 'stretch',
|
||||
height: 3,
|
||||
marginTop: 8,
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 999,
|
||||
},
|
||||
indicatorActive: {
|
||||
backgroundColor: '#C7FF00',
|
||||
},
|
||||
});
|
||||
210
components/bestai/community-grid.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
Pressable,
|
||||
Image as RNImage,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
export type CommunityItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
image: string;
|
||||
chip: string;
|
||||
actionLabel: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type CommunityGridProps = {
|
||||
items: CommunityItem[];
|
||||
onPressCard?: (item: CommunityItem) => void;
|
||||
onPressAction?: (item: CommunityItem) => void;
|
||||
};
|
||||
|
||||
const EMPTY_ITEM: CommunityItem = {
|
||||
id: 'empty',
|
||||
title: '',
|
||||
image: '',
|
||||
chip: '',
|
||||
actionLabel: '',
|
||||
};
|
||||
|
||||
export function CommunityGrid({ items, onPressCard, onPressAction }: CommunityGridProps) {
|
||||
const renderItem = ({ item, index }: ListRenderItemInfo<CommunityItem>) => {
|
||||
const isEmpty = item.id === 'empty';
|
||||
const isLeftColumn = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.cardWrapper, isLeftColumn ? styles.cardLeft : styles.cardRight]}>
|
||||
{isEmpty ? (
|
||||
<View style={styles.emptyCard} />
|
||||
) : (
|
||||
<CommunityCard
|
||||
item={item}
|
||||
onPressCard={onPressCard}
|
||||
onPressAction={onPressAction}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const data = items.length === 0
|
||||
? [EMPTY_ITEM, EMPTY_ITEM]
|
||||
: items.length % 2 === 1
|
||||
? [...items, EMPTY_ITEM]
|
||||
: items;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
scrollEnabled={false}
|
||||
columnWrapperStyle={styles.row}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type CommunityCardProps = {
|
||||
item: CommunityItem;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onPressCard?: (item: CommunityItem) => void;
|
||||
onPressAction?: (item: CommunityItem) => void;
|
||||
};
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player'
|
||||
|
||||
export const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
|
||||
const isVideo = /\.(mp4|webm|ogg|mov|avi)$/i.test(item.image);
|
||||
|
||||
return (
|
||||
<View style={[styles.card, style]}>
|
||||
<Pressable onPress={() => onPressCard?.(item)} style={styles.imageWrapper}>
|
||||
{isVideo ? (
|
||||
<VideoPlayer
|
||||
source={{ uri: item.image }}
|
||||
originalImage={item.image}
|
||||
style={[styles.cardImage, item.height ? { height: item.height } : {}]}
|
||||
/>
|
||||
) : (
|
||||
<ExpoImage
|
||||
source={{ uri: item.image }}
|
||||
style={[styles.cardImage, item.height ? { height: item.height } : {}]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<LinearGradient
|
||||
colors={['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.8)']}
|
||||
style={styles.imageGradient}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>{item.chip}</Text>
|
||||
</Pressable>
|
||||
<ActionButton label={item.actionLabel} onPress={() => onPressAction?.(item)} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
CommunityCard.displayName = 'CommunityCard';
|
||||
|
||||
type ActionButtonProps = {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const ActionButton = memo(({ label, onPress }: ActionButtonProps) => {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}>
|
||||
<RNImage
|
||||
source={require('@/assets/icons/start.svg')}
|
||||
style={styles.buttonIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.buttonText}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
ActionButton.displayName = 'CommunityActionButton';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingBottom: 12,
|
||||
},
|
||||
row: {
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 18,
|
||||
},
|
||||
cardWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
cardLeft: {
|
||||
marginRight: 8,
|
||||
},
|
||||
cardRight: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
},
|
||||
imageWrapper: {
|
||||
position: 'relative',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
},
|
||||
imageGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
},
|
||||
cardTitle: {
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
buttonIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
tintColor: '#C7FF00',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#C7FF00',
|
||||
},
|
||||
});
|
||||
114
components/bestai/feature-carousel.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { memo } from 'react';
|
||||
import { Dimensions, FlatList, ListRenderItem, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
const WINDOW_WIDTH = Dimensions.get('window').width;
|
||||
const CARD_HORIZONTAL_GUTTER = 18;
|
||||
const CARD_WIDTH = Math.min(WINDOW_WIDTH - 124, 360);
|
||||
|
||||
export type FeatureItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
type FeatureCarouselProps = {
|
||||
items: FeatureItem[];
|
||||
onPress?: (item: FeatureItem) => void;
|
||||
};
|
||||
|
||||
export function FeatureCarousel({ items, onPress }: FeatureCarouselProps) {
|
||||
const cardInterval = CARD_WIDTH + CARD_HORIZONTAL_GUTTER;
|
||||
|
||||
const renderItem: ListRenderItem<FeatureItem> = ({ item }) => <FeatureCard item={item} onPress={onPress} />;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
snapToInterval={cardInterval}
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FeatureCardProps = {
|
||||
item: FeatureItem;
|
||||
onPress?: (item: FeatureItem) => void;
|
||||
};
|
||||
|
||||
const FeatureCard = memo(({ item, onPress }: FeatureCardProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress?.(item)}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
pressed && {
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<Image source={{ uri: item.image }} style={styles.image} contentFit="cover" />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<Text style={styles.cardSubtitle}>{item.subtitle}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
FeatureCard.displayName = 'FeatureCard';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingVertical: 12,
|
||||
paddingRight: 24,
|
||||
},
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
marginRight: CARD_HORIZONTAL_GUTTER,
|
||||
borderRadius: 24,
|
||||
padding: 0,
|
||||
paddingBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1B1C20',
|
||||
backgroundColor: '#0F1013',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 12,
|
||||
},
|
||||
imageContainer: {
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 184,
|
||||
},
|
||||
cardContent: {
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#F4F8FF',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#B4BBC5',
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
92
components/bestai/header.tsx
Normal file
10
components/bestai/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { PageLayout, StatusBarSpacer } from './layout';
|
||||
export { Header } from './header';
|
||||
export { CategoryTabs } from './category-tabs';
|
||||
export { FeatureCarousel } from './feature-carousel';
|
||||
export { SectionHeader } from './section-header';
|
||||
export { CommunityGrid, CommunityCard } from './community-grid';
|
||||
export { Waterfall } from './waterfall';
|
||||
|
||||
export type { FeatureItem } from './feature-carousel';
|
||||
export type { CommunityItem } from './community-grid';
|
||||
54
components/bestai/layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type PageLayoutProps = PropsWithChildren<{
|
||||
backgroundColor?: string;
|
||||
topInset?: 'auto' | 'none' | number;
|
||||
horizontalPadding?: number | false;
|
||||
}>;
|
||||
|
||||
export function PageLayout({
|
||||
children,
|
||||
backgroundColor = '#050505',
|
||||
topInset = 'auto',
|
||||
horizontalPadding = 4,
|
||||
}: PageLayoutProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const spacingTop = topInset === 'auto' ? insets.top : topInset === 'none' ? 0 : topInset;
|
||||
const spacingBottom = Math.max(insets.bottom, 16);
|
||||
const spacingHorizontal = horizontalPadding === false ? 0 : horizontalPadding;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop: spacingTop,
|
||||
paddingBottom: spacingBottom,
|
||||
paddingHorizontal: spacingHorizontal,
|
||||
backgroundColor
|
||||
}
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const StatusBarSpacer = memo(function StatusBarSpacer() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!insets.top) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <View style={{ height: insets.top }} />;
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
35
components/bestai/section-header.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
type SectionHeaderProps = {
|
||||
title: string;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
export function SectionHeader({ title, onPress }: SectionHeaderProps) {
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 28,
|
||||
marginBottom: 18,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.2,
|
||||
color: '#F5F8FF',
|
||||
},
|
||||
});
|
||||
86
components/bestai/waterfall.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { View, StyleSheet, type ViewStyle } from 'react-native';
|
||||
|
||||
type WaterfallItem = {
|
||||
w: number;
|
||||
h: number;
|
||||
data: any;
|
||||
};
|
||||
|
||||
type WaterfallProps<T> = {
|
||||
items: WaterfallItem[];
|
||||
column?: number;
|
||||
itemPadding?: number;
|
||||
renderItem: (item: T, width: number, height: number) => React.ReactNode;
|
||||
containerWidth: number;
|
||||
};
|
||||
|
||||
function WaterfallComponent<T>({
|
||||
items,
|
||||
column = 2,
|
||||
itemPadding = 8,
|
||||
renderItem,
|
||||
containerWidth,
|
||||
}: WaterfallProps<T>) {
|
||||
const layout = useMemo(() => {
|
||||
const columns: { h: number; items: Array<WaterfallItem & { x: number; y: number }> }[] =
|
||||
Array.from({ length: column }, () => ({ h: 0, items: [] }));
|
||||
|
||||
const itemWidth = (containerWidth - itemPadding * (column - 1)) / column;
|
||||
|
||||
items.forEach((item) => {
|
||||
const minColumn = columns.reduce((min, col, idx) =>
|
||||
col.h < columns[min].h ? idx : min, 0);
|
||||
|
||||
const col = columns[minColumn];
|
||||
const layoutItem = {
|
||||
...item,
|
||||
x: minColumn * (itemWidth + itemPadding),
|
||||
y: col.h > 0 ? col.h + itemPadding : 0,
|
||||
w: itemWidth,
|
||||
};
|
||||
|
||||
col.h = layoutItem.y + item.h + 40;
|
||||
col.items.push(layoutItem);
|
||||
});
|
||||
|
||||
const maxHeight = Math.max(...columns.map(col => col.h));
|
||||
const allItems = columns.flatMap(col => col.items);
|
||||
|
||||
return { items: allItems, height: maxHeight };
|
||||
}, [items, column, itemPadding, containerWidth]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { height: layout.height }]}>
|
||||
{layout.items.map((item, index) => (
|
||||
<View
|
||||
key={item.data.id || index}
|
||||
style={[
|
||||
styles.item,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: item.x,
|
||||
top: item.y,
|
||||
width: item.w,
|
||||
height: item.h + 48
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderItem(item.data, item.w, item.h)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
item: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export const Waterfall = memo(WaterfallComponent) as typeof WaterfallComponent;
|
||||
105
components/empty-state.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
} from 'react-native';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { Colors, Spacing, BorderRadius, FontSize, Shadow } from '@/constants/theme';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionText?: string;
|
||||
onAction?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = '📄',
|
||||
title,
|
||||
description,
|
||||
actionText,
|
||||
onAction,
|
||||
style,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.content}>
|
||||
<ThemedText style={styles.icon}>{icon}</ThemedText>
|
||||
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
|
||||
{description ? (
|
||||
<ThemedText style={styles.description}>
|
||||
{description}
|
||||
</ThemedText>
|
||||
) : null}
|
||||
|
||||
{actionText && onAction ? (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onAction}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionText}>
|
||||
{actionText}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: Colors.background.secondary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: Spacing.xxl,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 48,
|
||||
lineHeight: 64,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
color: Colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
description: {
|
||||
fontSize: FontSize.sm,
|
||||
color: Colors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
actionButton: {
|
||||
backgroundColor: Colors.brand.primary,
|
||||
paddingHorizontal: Spacing.xl,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.md,
|
||||
minWidth: 120,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...Shadow.small,
|
||||
},
|
||||
actionText: {
|
||||
color: '#ffffff',
|
||||
fontSize: FontSize.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
25
components/external-link.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
432
components/forms/dynamic-form-field.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { TemplateGraphNode } from '@/lib/types/template';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface DynamicFormFieldProps {
|
||||
node: TemplateGraphNode;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DynamicFormField({ node, value, onChange, error }: DynamicFormFieldProps) {
|
||||
const { type, data } = node;
|
||||
const { label, description, actionData } = data;
|
||||
|
||||
const renderField = () => {
|
||||
switch (type) {
|
||||
case 'select':
|
||||
return renderSelectField();
|
||||
case 'text':
|
||||
return renderTextField();
|
||||
case 'image':
|
||||
return renderImageField();
|
||||
case 'video':
|
||||
return renderVideoField();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectField = () => {
|
||||
const options = actionData?.options || [];
|
||||
const allowMultiple = actionData?.allowMultiple || false;
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (allowMultiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v: string) => v !== optionValue)
|
||||
: [...currentValues, optionValue];
|
||||
onChange(newValues);
|
||||
} else {
|
||||
onChange(optionValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (optionValue: string) => {
|
||||
if (allowMultiple) {
|
||||
return Array.isArray(value) && value.includes(optionValue);
|
||||
}
|
||||
return value === optionValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<View style={styles.selectOptions}>
|
||||
{options.map((option: any, index: number) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.selectOption,
|
||||
isSelected(option.value) && styles.selectOptionSelected,
|
||||
error && styles.selectOptionError,
|
||||
]}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.selectOptionText,
|
||||
isSelected(option.value) && styles.selectOptionTextSelected,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{isSelected(option.value) && (
|
||||
<Feather name="check" size={18} color="#050505" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTextField = () => {
|
||||
const placeholder = actionData?.placeholder || '请输入内容';
|
||||
const multiline = actionData?.multiline || false;
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
multiline && styles.textInputMultiline,
|
||||
error && styles.textInputError,
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#898A8B"
|
||||
value={value || ''}
|
||||
onChangeText={onChange}
|
||||
multiline={multiline}
|
||||
textAlignVertical={multiline ? 'top' : 'center'}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageField = () => {
|
||||
const handleImagePick = async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
'需要权限',
|
||||
'请在设置中允许访问相册以上传图片',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image:', error);
|
||||
Alert.alert('错误', '无法选择图片,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadCard,
|
||||
value && styles.uploadCardFilled,
|
||||
error && styles.uploadCardError,
|
||||
]}
|
||||
onPress={handleImagePick}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
<Image source={{ uri: value }} style={styles.uploadImage} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={handleRemove}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Feather name="x" size={16} color="#050505" />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.uploadIconWrap}>
|
||||
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||
</View>
|
||||
<Text style={styles.uploadLabel}>上传图片</Text>
|
||||
<Text style={styles.uploadDescription}>
|
||||
{actionData?.placeholder || 'PNG, JPG 格式'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderVideoField = () => {
|
||||
const handleVideoPick = async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
'需要权限',
|
||||
'请在设置中允许访问相册以上传视频',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick video:', error);
|
||||
Alert.alert('错误', '无法选择视频,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
{description && <Text style={styles.fieldDescription}>{description}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadCard,
|
||||
value && styles.uploadCardFilled,
|
||||
error && styles.uploadCardError,
|
||||
]}
|
||||
onPress={handleVideoPick}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
<View style={styles.videoPreview}>
|
||||
<Feather name="video" size={40} color="#D1FF00" />
|
||||
<Text style={styles.videoPreviewText}>视频已选择</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={handleRemove}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Feather name="x" size={16} color="#050505" />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.uploadIconWrap}>
|
||||
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||
</View>
|
||||
<Text style={styles.uploadLabel}>上传视频</Text>
|
||||
<Text style={styles.uploadDescription}>
|
||||
{actionData?.placeholder || 'MP4, MOV 格式'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return renderField();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fieldContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
fieldDescription: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
selectOptions: {
|
||||
gap: 12,
|
||||
},
|
||||
selectOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
},
|
||||
selectOptionSelected: {
|
||||
backgroundColor: '#D1FF00',
|
||||
borderColor: '#D1FF00',
|
||||
},
|
||||
selectOptionError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
selectOptionText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
selectOptionTextSelected: {
|
||||
color: '#050505',
|
||||
},
|
||||
textInput: {
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
fontSize: 15,
|
||||
color: '#FFFFFF',
|
||||
minHeight: 105,
|
||||
},
|
||||
textInputMultiline: {
|
||||
minHeight: 140,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
textInputError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
uploadCard: {
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 188,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
uploadCardFilled: {
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
uploadCardError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
uploadIconWrap: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(209, 255, 0, 0.12)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
uploadLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.6,
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
},
|
||||
uploadDescription: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
uploadImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
videoPreview: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
videoPreviewText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#D1FF00',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#D1FF00',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
});
|
||||
373
components/forms/dynamic-form.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { View, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { RunFormSchema, RunTemplateData, FormFieldSchema } from '@/lib/types/template-run';
|
||||
import { TextInputField } from './form-fields/text-input';
|
||||
import { NumberInputField } from './form-fields/number-input';
|
||||
import { SelectInputField } from './form-fields/select-input';
|
||||
import { ImageUploadField } from './form-fields/image-upload';
|
||||
import { ColorInputField } from './form-fields/color-input';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
||||
|
||||
interface DynamicFormProps {
|
||||
schema: RunFormSchema;
|
||||
initialData?: RunTemplateData;
|
||||
onDataChange?: (data: RunTemplateData, isValid: boolean) => void;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function DynamicForm({
|
||||
schema,
|
||||
initialData = {},
|
||||
onDataChange,
|
||||
onSubmit
|
||||
}: DynamicFormProps) {
|
||||
const [formData, setFormData] = useState<RunTemplateData>(initialData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [schemaError, setSchemaError] = useState<string | null>(null);
|
||||
|
||||
// 验证schema并初始化默认值
|
||||
useEffect(() => {
|
||||
setIsValidating(true);
|
||||
setSchemaError(null);
|
||||
|
||||
try {
|
||||
if (!validateFormSchema(schema)) {
|
||||
setSchemaError('表单配置格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultData: RunTemplateData = {};
|
||||
schema.fields.forEach(field => {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultData[field.name] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
const finalInitialData = { ...defaultData, ...initialData };
|
||||
setFormData(finalInitialData);
|
||||
|
||||
if (onDataChange) {
|
||||
onDataChange(finalInitialData, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单初始化失败:', error);
|
||||
setSchemaError('表单初始化失败');
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [schema, initialData]);
|
||||
|
||||
// 验证单个字段
|
||||
const validateField = useCallback((field: FormFieldSchema, value: any): string | null => {
|
||||
// 检查必填
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
return `${field.label} 是必填项`;
|
||||
}
|
||||
|
||||
// 如果值为空且不是必填,则跳过其他验证
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据类型验证
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
if (typeof value !== 'string') {
|
||||
return `${field.label} 必须是文本`;
|
||||
}
|
||||
if (field.min && value.length < field.min) {
|
||||
return `${field.label} 至少需要 ${field.min} 个字符`;
|
||||
}
|
||||
if (field.max && value.length > field.max) {
|
||||
return `${field.label} 最多 ${field.max} 个字符`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue)) {
|
||||
return `${field.label} 必须是数字`;
|
||||
}
|
||||
if (field.min !== undefined && numValue < field.min) {
|
||||
return `${field.label} 不能小于 ${field.min}`;
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
return `${field.label} 不能大于 ${field.max}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
const isValidOption = field.options?.some(option => option.value === value);
|
||||
if (!isValidOption) {
|
||||
return `${field.label} 选择了无效的选项`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
if (typeof value !== 'string') {
|
||||
return `${field.label} 必须是图片URL`;
|
||||
}
|
||||
if (value && !value.startsWith('http') && !value.startsWith('file://')) {
|
||||
return `${field.label} 必须是有效的图片地址`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'color':
|
||||
if (typeof value !== 'string') {
|
||||
return `${field.label} 必须是颜色值`;
|
||||
}
|
||||
if (value && !/^#[0-9A-F]{6}$/i.test(value)) {
|
||||
return `${field.label} 必须是有效的颜色值 (如 #FF0000)`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 更新表单数据
|
||||
const updateField = useCallback((fieldName: string, value: any) => {
|
||||
const newFormData = { ...formData, [fieldName]: value };
|
||||
setFormData(newFormData);
|
||||
|
||||
// 只验证当前字段
|
||||
const field = schema.fields.find(f => f.name === fieldName);
|
||||
if (!field) return;
|
||||
|
||||
const fieldError = validateField(field, value);
|
||||
const newErrors = { ...errors };
|
||||
|
||||
if (fieldError) {
|
||||
newErrors[fieldName] = fieldError;
|
||||
} else {
|
||||
delete newErrors[fieldName];
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
// 通知父组件数据变化
|
||||
if (onDataChange) {
|
||||
const isValid = Object.keys(newErrors).length === 0;
|
||||
onDataChange(newFormData, isValid);
|
||||
}
|
||||
}, [formData, schema.fields, errors, validateField, onDataChange]);
|
||||
|
||||
// 渲染表单字段
|
||||
const renderField = useCallback((field: FormFieldSchema) => {
|
||||
const value = formData[field.name];
|
||||
const error = errors[field.name];
|
||||
const onChange = (newValue: any) => updateField(field.name, newValue);
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return <TextInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||||
|
||||
case 'number':
|
||||
return <NumberInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||||
|
||||
case 'select':
|
||||
return <SelectInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||||
|
||||
case 'image':
|
||||
return <ImageUploadField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||||
|
||||
case 'color':
|
||||
return <ColorInputField key={field.name} field={field} value={value} onChange={onChange} error={error} />;
|
||||
|
||||
default:
|
||||
console.warn(`不支持的表单字段类型: ${field.type}`);
|
||||
return null;
|
||||
}
|
||||
}, [formData, errors, updateField]);
|
||||
|
||||
// 显示加载状态
|
||||
if (isValidating) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<ThemedText style={styles.loadingText}>正在加载表单...</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示schema错误
|
||||
if (schemaError) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorTitle}>表单加载失败</ThemedText>
|
||||
<ThemedText style={styles.errorText}>{schemaError}</ThemedText>
|
||||
<ThemedText style={styles.errorHint}>
|
||||
请稍后重试,或联系技术支持。
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<ThemedView style={styles.content}>
|
||||
{schema.fields.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<ThemedText style={styles.emptyIcon}>📋</ThemedText>
|
||||
<ThemedText style={styles.emptyText}>无需配置参数</ThemedText>
|
||||
<ThemedText style={styles.emptyDescription}>
|
||||
此模板使用默认参数,可以直接开始生成
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<ThemedView style={styles.formHeader}>
|
||||
<ThemedText style={styles.formTitle}>请填写以下信息</ThemedText>
|
||||
<ThemedText style={styles.formDescription}>
|
||||
完善配置信息以获得最佳的生成效果
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{schema.fields.map(field => renderField(field))}
|
||||
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<ThemedView style={styles.errorSummary}>
|
||||
<ThemedText style={styles.errorSummaryText}>
|
||||
⚠️ 请修正 {Object.keys(errors).length} 个错误后再提交
|
||||
</ThemedText>
|
||||
{Object.entries(errors).map(([fieldName, error]) => (
|
||||
<ThemedText key={fieldName} style={styles.errorItem}>
|
||||
• {error}
|
||||
</ThemedText>
|
||||
))}
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
{Object.keys(errors).length === 0 && formData && Object.keys(formData).length > 0 && (
|
||||
<ThemedView style={styles.successSummary}>
|
||||
<ThemedText style={styles.successText}>
|
||||
✓ 配置信息完整,可以开始生成
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
// 加载状态样式
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
// 错误状态样式
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FF3B30',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorHint: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.6,
|
||||
},
|
||||
// 空状态样式
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
opacity: 0.8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.6,
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 表单头部样式
|
||||
formHeader: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
formDescription: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 错误总结样式
|
||||
errorSummary: {
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#FF3B30',
|
||||
},
|
||||
errorSummaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FF3B30',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorItem: {
|
||||
fontSize: 14,
|
||||
color: '#FF3B30',
|
||||
marginBottom: 4,
|
||||
opacity: 0.9,
|
||||
},
|
||||
// 成功状态样式
|
||||
successSummary: {
|
||||
backgroundColor: 'rgba(52, 199, 89, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#34C759',
|
||||
},
|
||||
successText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#34C759',
|
||||
},
|
||||
});
|
||||
189
components/forms/form-fields/color-input.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
interface ColorInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||||
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2',
|
||||
'#F8B739', '#52B788', '#F72585', '#7209B7', '#3A0CA3',
|
||||
'#000000', '#FFFFFF', '#808080', '#FF0000', '#00FF00'
|
||||
];
|
||||
|
||||
export function ColorInputField({ field, value, onChange, error }: ColorInputFieldProps) {
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
onChange(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{/* 当前选中的颜色 */}
|
||||
<View style={styles.currentColorContainer}>
|
||||
<ThemedText style={styles.currentColorLabel}>当前颜色:</ThemedText>
|
||||
<View style={styles.currentColorRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.currentColorBox,
|
||||
{ backgroundColor: value || '#000000' }
|
||||
]}
|
||||
/>
|
||||
<ThemedText style={styles.currentColorValue}>
|
||||
{value || '#000000'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 颜色选择器 */}
|
||||
<View style={[
|
||||
styles.colorPicker,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}>
|
||||
<View style={styles.colorGrid}>
|
||||
{DEFAULT_COLORS.map((color, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{
|
||||
backgroundColor: color,
|
||||
borderWidth: value === color ? 3 : 1,
|
||||
borderColor: value === color ? '#007AFF' : borderColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleColorSelect(color)}
|
||||
activeOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 自定义颜色输入 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.customColorButton,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => {
|
||||
// 这里可以集成更高级的颜色选择器
|
||||
// 目前使用简单的颜色切换
|
||||
const randomColor = '#' + Math.floor(Math.random()*16777215).toString(16);
|
||||
handleColorSelect(randomColor);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.customColorText}>
|
||||
随机颜色
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
currentColorContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
currentColorLabel: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
currentColorRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
currentColorBox: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
currentColorValue: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
colorPicker: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
colorGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
colorOption: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
marginBottom: 4,
|
||||
},
|
||||
customColorButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
customColorText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
229
components/forms/form-fields/image-upload.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ImageUploadField({ field, value, onChange, error }: ImageUploadFieldProps) {
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const placeholderColor = useThemeColor({}, 'textPlaceholder');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const pickImage = async () => {
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// 请求权限
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限请求', '需要相册权限才能选择图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 选择图片
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择图片失败:', error);
|
||||
Alert.alert('错误', '选择图片失败,请重试');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const takePhoto = async () => {
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// 请求相机权限
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限请求', '需要相机权限才能拍照');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拍照
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('拍照失败:', error);
|
||||
Alert.alert('错误', '拍照失败,请重试');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showOptions = () => {
|
||||
Alert.alert(
|
||||
'选择图片',
|
||||
'',
|
||||
[
|
||||
{
|
||||
text: '从相册选择',
|
||||
onPress: pickImage,
|
||||
},
|
||||
{
|
||||
text: '拍照',
|
||||
onPress: takePhoto,
|
||||
},
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const removeImage = () => {
|
||||
onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{value ? (
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: value }}
|
||||
style={styles.image}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.removeButton, { backgroundColor: errorColor }]}
|
||||
onPress={removeImage}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.removeButtonText}>✕</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadButton,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
onPress={showOptions}
|
||||
disabled={uploading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={[styles.uploadIcon, { color: placeholderColor }]}>
|
||||
📷
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.uploadText, { color: placeholderColor }]}>
|
||||
{uploading ? '上传中...' : (field.placeholder || '点击上传图片')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
imageContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
borderRadius: 8,
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
uploadButton: {
|
||||
borderWidth: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
uploadIcon: {
|
||||
fontSize: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
uploadText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
113
components/forms/form-fields/number-input.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { TextInput, View, StyleSheet } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
interface NumberInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: number | string;
|
||||
onChange: (value: number) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function NumberInputField({ field, value, onChange, error }: NumberInputFieldProps) {
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const placeholderColor = useThemeColor({}, 'textPlaceholder');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
const num = parseFloat(text);
|
||||
if (!isNaN(num)) {
|
||||
onChange(num);
|
||||
} else if (text === '') {
|
||||
onChange(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
value={value?.toString() || ''}
|
||||
onChangeText={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
placeholderTextColor={placeholderColor}
|
||||
keyboardType="numeric"
|
||||
maxLength={10}
|
||||
/>
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{(field.min !== undefined || field.max !== undefined) && (
|
||||
<ThemedText style={styles.hint}>
|
||||
{field.min !== undefined && field.max !== undefined
|
||||
? `范围: ${field.min} - ${field.max}`
|
||||
: field.min !== undefined
|
||||
? `最小值: ${field.min}`
|
||||
: `最大值: ${field.max}`}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
opacity: 0.6,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
164
components/forms/form-fields/select-input.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SelectInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string | number;
|
||||
onChange: (value: string | number) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function SelectInputField({ field, value, onChange, error }: SelectInputFieldProps) {
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const selectedOption = field.options?.find(option => option.value === value);
|
||||
|
||||
const handleSelect = (option: any) => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.selectBox,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => setIsOpen(!isOpen)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={[styles.selectedText, { color: selectedOption ? textColor : '#999' }]}>
|
||||
{selectedOption?.label || field.placeholder || '请选择...'}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.arrow, { color: textColor }]}>
|
||||
{isOpen ? '▲' : '▼'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isOpen && field.options && (
|
||||
<View style={[styles.dropdown, { backgroundColor, borderColor }]}>
|
||||
{field.options.map((option, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.option,
|
||||
{
|
||||
backgroundColor: option.value === value ? borderColor : 'transparent',
|
||||
borderBottomColor: borderColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleSelect(option)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={[
|
||||
styles.optionText,
|
||||
{
|
||||
color: option.value === value ? '#fff' : textColor,
|
||||
fontWeight: option.value === value ? '600' : '400',
|
||||
}
|
||||
]}>
|
||||
{option.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
selectBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
minHeight: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
selectedText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
arrow: {
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
dropdown: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
maxHeight: 200,
|
||||
elevation: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
},
|
||||
option: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
90
components/forms/form-fields/text-input.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { TextInput, View, StyleSheet } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
interface TextInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function TextInputField({ field, value, onChange, error }: TextInputFieldProps) {
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const placeholderColor = useThemeColor({}, 'textPlaceholder');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
value={value || ''}
|
||||
onChangeText={onChange}
|
||||
placeholder={field.placeholder}
|
||||
placeholderTextColor={placeholderColor}
|
||||
multiline={field.type === 'textarea'}
|
||||
numberOfLines={field.type === 'textarea' ? 4 : 1}
|
||||
textAlignVertical={field.type === 'textarea' ? 'top' : 'center'}
|
||||
/>
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
18
components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
components/hello-wave.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
241
components/image/fullscreen-image-modal.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
PanResponder,
|
||||
Platform,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
export interface FullscreenImageModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
imageUrl: string;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
hasNext?: boolean;
|
||||
hasPrevious?: boolean;
|
||||
}
|
||||
|
||||
export function FullscreenImageModal({
|
||||
visible,
|
||||
onClose,
|
||||
imageUrl,
|
||||
onPrevious,
|
||||
onNext,
|
||||
hasNext = false,
|
||||
hasPrevious = false,
|
||||
}: FullscreenImageModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// 重置状态当模态框打开/关闭时
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 处理图片加载完成
|
||||
const handleImageLoad = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = (error: any) => {
|
||||
console.error('图片加载错误:', error);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 图片点击处理(直接关闭)
|
||||
const handleImagePress = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 创建手势处理器
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||
// 只有水平移动时才响应
|
||||
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
|
||||
|
||||
if (gestureState.dx > threshold && hasPrevious) {
|
||||
// 向右滑动,显示上一个
|
||||
onPrevious?.();
|
||||
} else if (gestureState.dx < -threshold && hasNext) {
|
||||
// 向左滑动,显示下一个
|
||||
onNext?.();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
// 处理返回键(Android)
|
||||
useEffect(() => {
|
||||
const handleBackPress = () => {
|
||||
if (visible) {
|
||||
handleClose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
return () => subscription.remove();
|
||||
}, [visible, handleClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={false}
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
statusBarTranslucent={true}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* 状态栏处理 */}
|
||||
{Platform.OS === 'android' && <StatusBar hidden />}
|
||||
|
||||
{/* 图片容器 */}
|
||||
<TouchableOpacity
|
||||
style={styles.imageContainer}
|
||||
onPress={handleImagePress}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View
|
||||
style={styles.imageWrapper}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* 图片显示 */}
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.image}
|
||||
contentFit="contain"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||
transition={300}
|
||||
/>
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 左右导航指示器 */}
|
||||
{hasPrevious && (
|
||||
<TouchableOpacity
|
||||
style={styles.leftIndicator}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onPrevious?.();
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={styles.indicatorIcon}>‹</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{hasNext && (
|
||||
<TouchableOpacity
|
||||
style={styles.rightIndicator}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onNext?.();
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={styles.indicatorIcon}>›</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
imageContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageWrapper: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
image: {
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
leftIndicator: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
top: '50%',
|
||||
marginTop: -20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
rightIndicator: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: '50%',
|
||||
marginTop: -20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
indicatorIcon: {
|
||||
fontSize: 24,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default FullscreenImageModal;
|
||||
310
components/media/fullscreen-media-modal.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
// ResizeMode 兼容映射
|
||||
const ResizeMode = {
|
||||
CONTAIN: 'contain' as const,
|
||||
COVER: 'cover' as const,
|
||||
STRETCH: 'fill' as const,
|
||||
};
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
PanResponder,
|
||||
Platform,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import { isVideoTemplate, canLoadMedia, getEffectiveMediaUrl } from '@/utils/media-utils';
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
export interface FullscreenMediaModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentIndex: number;
|
||||
templates: Template[];
|
||||
onIndexChanged: (index: number) => void;
|
||||
}
|
||||
|
||||
|
||||
export function FullscreenMediaModal({
|
||||
visible,
|
||||
onClose,
|
||||
currentIndex,
|
||||
templates,
|
||||
onIndexChanged,
|
||||
}: FullscreenMediaModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
// 获取当前模板
|
||||
const currentTemplate = templates[currentIndex];
|
||||
const isCurrentVideo = currentTemplate ? isVideoTemplate(currentTemplate) : false;
|
||||
const canLoadCurrentMedia = currentTemplate ? canLoadMedia(currentTemplate) : false;
|
||||
|
||||
// 重置状态当模态框打开/关闭时
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsLoading(true);
|
||||
// 添加备用机制:3秒后自动隐藏loading,防止卡住
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, currentIndex]);
|
||||
|
||||
const markMediaLoaded = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 处理Web平台视频加载完成
|
||||
const handleWebVideoLoad = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 处理媒体加载错误
|
||||
const handleMediaError = (error: any) => {
|
||||
console.error('媒体加载错误:', {
|
||||
template: currentTemplate?.title,
|
||||
mediaType: isCurrentVideo ? 'video' : 'image',
|
||||
url: getEffectiveMediaUrl(currentTemplate),
|
||||
error: error
|
||||
});
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 媒体点击处理(直接关闭)
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleMediaPress = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 统一的切换处理函数
|
||||
const handlePrevious = () => {
|
||||
if (currentIndex > 0) {
|
||||
const newIndex = currentIndex - 1;
|
||||
onIndexChanged(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex < templates.length - 1) {
|
||||
const newIndex = currentIndex + 1;
|
||||
onIndexChanged(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建手势处理器
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||
// 只有水平移动时才响应
|
||||
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
|
||||
|
||||
if (gestureState.dx > threshold && currentIndex > 0) {
|
||||
// 向右滑动,显示上一个
|
||||
handlePrevious();
|
||||
} else if (gestureState.dx < -threshold && currentIndex < templates.length - 1) {
|
||||
// 向左滑动,显示下一个
|
||||
handleNext();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
// 处理返回键(Android)
|
||||
useEffect(() => {
|
||||
const handleBackPress = () => {
|
||||
if (visible) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
return () => subscription.remove();
|
||||
}, [visible, onClose]);
|
||||
|
||||
if (!visible || !currentTemplate) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={false}
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
statusBarTranslucent={true}
|
||||
>
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
{/* 状态栏处理 */}
|
||||
{Platform.OS === 'android' && <StatusBar hidden />}
|
||||
|
||||
{/* 媒体容器 */}
|
||||
<TouchableOpacity
|
||||
style={styles.mediaContainer}
|
||||
onPress={handleMediaPress}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View
|
||||
style={styles.mediaWrapper}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* 根据模板类型显示对应内容 */}
|
||||
{isCurrentVideo && canLoadCurrentMedia ? (
|
||||
// 视频显示
|
||||
<VideoPlayer
|
||||
source={{ uri: currentTemplate.previewUrl }}
|
||||
style={styles.video}
|
||||
resizeMode={ResizeMode.COVER}
|
||||
shouldPlay={true}
|
||||
isLooping={true}
|
||||
isMuted={false}
|
||||
useNativeControls={false}
|
||||
showPoster={false}
|
||||
autoPlay={true}
|
||||
fullscreenMode={true}
|
||||
onPress={handleMediaPress}
|
||||
onReady={(_status) => markMediaLoaded()}
|
||||
onWebLoadedData={handleWebVideoLoad}
|
||||
onError={handleMediaError}
|
||||
/>
|
||||
) : (
|
||||
// 图片显示(包括视频无法加载时的降级显示)
|
||||
<Image
|
||||
source={{ uri: currentTemplate.coverImageUrl || '' }}
|
||||
style={styles.image}
|
||||
contentFit="contain"
|
||||
onLoad={() => markMediaLoaded()}
|
||||
onError={handleMediaError}
|
||||
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||
transition={300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 左右导航指示器 */}
|
||||
{currentIndex > 0 && (
|
||||
<TouchableOpacity
|
||||
style={styles.leftIndicator}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePrevious();
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={styles.indicatorIcon}>‹</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{currentIndex < templates.length - 1 && (
|
||||
<TouchableOpacity
|
||||
style={styles.rightIndicator}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNext();
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={styles.indicatorIcon}>›</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
mediaContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
mediaWrapper: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
image: {
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
video: {
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
leftIndicator: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
top: '50%',
|
||||
marginTop: -20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
rightIndicator: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: '50%',
|
||||
marginTop: -20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
indicatorIcon: {
|
||||
fontSize: 24,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default FullscreenMediaModal;
|
||||
77
components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor['dark'] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
329
components/profile/content-gallery.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { router } from 'expo-router';
|
||||
import type { TemplateGeneration } from '@/lib/api/template-generations';
|
||||
|
||||
/**
|
||||
* 根据文件 URL 后缀名判断媒体类型
|
||||
*/
|
||||
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||
if (!url) return 'unknown';
|
||||
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||
|
||||
if (imageExts.includes(extension)) return 'image';
|
||||
if (videoExts.includes(extension)) return 'video';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function ContentGallery({
|
||||
generations,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
ListHeaderComponent,
|
||||
}: {
|
||||
generations: TemplateGeneration[];
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
|
||||
}) {
|
||||
const palette = darkPalette;
|
||||
|
||||
const renderItem = ({ item }: { item: TemplateGeneration }) => (
|
||||
<ContentItem palette={palette} generation={item} />
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (isLoadingMore) {
|
||||
return (
|
||||
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ActivityIndicator size="small" color={palette.accent} />
|
||||
<ThemedText style={[styles.loadingMoreText, { color: palette.textSecondary }]}>
|
||||
加载更多...
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMore && generations.length > 0) {
|
||||
return (
|
||||
<View style={[styles.noMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ThemedText style={[styles.noMoreText, { color: palette.textSecondary }]}>
|
||||
没有更多内容了
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={generations}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={renderFooter}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item.id}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={palette.accent}
|
||||
colors={[palette.accent]}
|
||||
/>
|
||||
}
|
||||
onEndReached={onLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={6}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={10}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ContentItem = memo(function ContentItem({
|
||||
palette,
|
||||
generation,
|
||||
}: {
|
||||
palette: any;
|
||||
generation: TemplateGeneration;
|
||||
}) {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const renderMedia = () => {
|
||||
const mediaUrl = generation.resultUrl[0];
|
||||
|
||||
if (!mediaUrl) {
|
||||
return (
|
||||
<View style={[styles.mediaContainer, styles.placeholderContainer]}>
|
||||
<ThemedText style={styles.placeholderText}>
|
||||
{generation.type === 'VIDEO' ? '🎬' : generation.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaType = getMediaType(mediaUrl);
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<video
|
||||
src={mediaUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover' as any,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<VideoPlayer
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaVideo}
|
||||
shouldPlay={false}
|
||||
isLooping={true}
|
||||
isMuted={true}
|
||||
useNativeControls={false}
|
||||
showPoster={true}
|
||||
maxHeight={300}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
backgroundColor: palette.surface,
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/result?generationId=${generation.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{renderMedia()}
|
||||
<View style={styles.contentInfo}>
|
||||
<ThemedText
|
||||
style={[styles.contentTitle, { color: palette.textPrimary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{generation.template?.title || '未知模板'}
|
||||
</ThemedText>
|
||||
<View style={styles.contentMeta}>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialIcons name="schedule" size={14} color={palette.textSecondary} />
|
||||
<ThemedText style={[styles.metaText, { color: palette.textSecondary }]}>
|
||||
{formatDate(generation.createdAt)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{generation.status !== 'completed' && (
|
||||
<View style={styles.statusBadge}>
|
||||
<ThemedText style={[styles.statusText, { color: palette.textSecondary }]}>
|
||||
{generation.status}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const darkPalette = {
|
||||
background: '#050505',
|
||||
surface: '#121216',
|
||||
border: '#1D1E24',
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
accent: '#B7FF2F',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
background: '#F7F8FB',
|
||||
surface: '#FFFFFF',
|
||||
border: '#E2E5ED',
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
accent: '#405CFF',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingBottom: 120,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
placeholderContainer: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
mediaImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mediaVideo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
contentInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
contentMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize' as const,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingMoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
noMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noMoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
102
components/profile/content-skeleton.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
export function ContentSkeleton() {
|
||||
const palette = darkPalette;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: palette.background }]}>
|
||||
<View style={styles.skeletonGrid}>
|
||||
<View style={styles.column}>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<SkeletonItem key={`left-${i}`} palette={palette} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.column}>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<SkeletonItem key={`right-${i}`} palette={palette} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonItem({ palette }: { palette: any }) {
|
||||
return (
|
||||
<View style={[styles.skeletonItem, { backgroundColor: palette.skeletonBackground }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.skeletonImage,
|
||||
{
|
||||
backgroundColor: palette.skeletonHighlight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={styles.skeletonContent}>
|
||||
<View
|
||||
style={[
|
||||
styles.skeletonLine,
|
||||
{
|
||||
backgroundColor: palette.skeletonHighlight,
|
||||
width: '80%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.skeletonLine,
|
||||
{
|
||||
backgroundColor: palette.skeletonHighlight,
|
||||
width: '60%',
|
||||
marginTop: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
background: '#050505',
|
||||
skeletonBackground: '#0D0E12',
|
||||
skeletonHighlight: '#1A1B20',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
background: '#F7F8FB',
|
||||
skeletonBackground: '#FFFFFF',
|
||||
skeletonHighlight: '#F0F2F8',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
skeletonGrid: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
column: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
skeletonItem: {
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
skeletonImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
},
|
||||
skeletonContent: {
|
||||
padding: 12,
|
||||
},
|
||||
skeletonLine: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
90
components/profile/content-tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'image', label: 'Image' },
|
||||
{ key: 'video', label: 'Video' },
|
||||
];
|
||||
|
||||
export function ContentTabs({
|
||||
activeTab,
|
||||
onChangeTab,
|
||||
isLoading = false,
|
||||
}: {
|
||||
activeTab: TabKey;
|
||||
onChangeTab: (tab: TabKey) => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const palette = darkPalette;
|
||||
|
||||
return (
|
||||
<View style={[styles.tabsBar]}>
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.key === activeTab;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => onChangeTab(tab.key)}
|
||||
disabled={isLoading}
|
||||
style={[
|
||||
styles.tabButton,
|
||||
{
|
||||
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{
|
||||
color: isActive ? palette.onAccent : palette.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
pill: '#16171C',
|
||||
border: '#1D1E24',
|
||||
tabActive: '#FFFFFF',
|
||||
onAccent: '#050505',
|
||||
textSecondary: '#8E9098',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
tabsBar: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'flex-start' as const,
|
||||
borderWidth: 0,
|
||||
borderRadius: 999,
|
||||
padding: 0,
|
||||
gap: 4,
|
||||
},
|
||||
tabButton: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
loadingIndicator: {
|
||||
height: 18,
|
||||
},
|
||||
};
|
||||
15
components/profile/divider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export function Divider() {
|
||||
const borderColor = '#1D1E24';
|
||||
|
||||
return <View style={[styles.divider, { backgroundColor: borderColor }]} />;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 8,
|
||||
},
|
||||
};
|
||||
9
components/profile/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ProfileScreen, default } from './profile-screen';
|
||||
export { ProfileHeader } from './profile-header';
|
||||
export { ProfileIdentity } from './profile-identity';
|
||||
export { ContentTabs } from './content-tabs';
|
||||
export { ContentGallery } from './content-gallery';
|
||||
export { ProfileEmptyState } from './profile-empty-state';
|
||||
export { ProfileLoadingState } from './profile-loading-state';
|
||||
export { ProfileErrorState } from './profile-error-state';
|
||||
export { Divider } from './divider';
|
||||
391
components/profile/profile-edit-modal.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ImageSourcePropType,
|
||||
} from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { deriveInitials } from '@/utils/profile-data';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { patchApiProfileName, patchApiProfileAvatar } from '@repo/loomart-sdk';
|
||||
import { loomartClient } from '@/lib/api/loomart-client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
type ProfileEditModalProps = {
|
||||
visible: boolean;
|
||||
avatarSource?: ImageSourcePropType;
|
||||
initialName: string;
|
||||
onClose: () => void;
|
||||
onSave: (payload: { name: string; avatar?: ImageSourcePropType }) => void;
|
||||
};
|
||||
|
||||
export function ProfileEditModal({
|
||||
visible,
|
||||
avatarSource,
|
||||
initialName,
|
||||
onClose,
|
||||
onSave,
|
||||
}: ProfileEditModalProps) {
|
||||
const palette = palettes.dark;
|
||||
const { refetch } = useAuth();
|
||||
const [nameValue, setNameValue] = useState(initialName);
|
||||
const [avatarCandidate, setAvatarCandidate] = useState<ImageSourcePropType | undefined>(avatarSource);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setNameValue(initialName);
|
||||
setAvatarCandidate(avatarSource);
|
||||
}, [visible, initialName, avatarSource]);
|
||||
|
||||
const trimmedName = nameValue.trim();
|
||||
const initialTrimmedName = initialName.trim();
|
||||
const hasNameChanged = trimmedName !== initialTrimmedName;
|
||||
const avatarKey = getSourceKey(avatarCandidate);
|
||||
const initialAvatarKey = getSourceKey(avatarSource);
|
||||
const hasAvatarChanged = avatarKey !== initialAvatarKey;
|
||||
const isSaveDisabled = trimmedName.length === 0 || (!hasNameChanged && !hasAvatarChanged);
|
||||
|
||||
const handlePickAvatar = useCallback(async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('需要权限', '请允许访问相册以选择头像');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.85,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
if (!asset.uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadResponse = await uploadFile(asset.uri, 'image');
|
||||
setIsUploading(false);
|
||||
|
||||
if (uploadResponse.success && uploadResponse.data?.url) {
|
||||
setAvatarCandidate({ uri: uploadResponse.data.url });
|
||||
} else {
|
||||
Alert.alert('上传失败', uploadResponse.message || '图片上传失败,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
console.error('Failed to pick avatar', error);
|
||||
Alert.alert('选择失败', '无法打开相册,请稍后重试');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (trimmedName.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (hasNameChanged) {
|
||||
const nameResponse = await patchApiProfileName({
|
||||
client: loomartClient,
|
||||
body: { name: trimmedName },
|
||||
});
|
||||
|
||||
if (!(nameResponse.data as any)?.success) {
|
||||
throw new Error('更新用户名失败');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAvatarChanged && avatarCandidate && (avatarCandidate as any).uri) {
|
||||
const avatarResponse = await patchApiProfileAvatar({
|
||||
client: loomartClient,
|
||||
body: { image: (avatarCandidate as any).uri! },
|
||||
});
|
||||
|
||||
if (!(avatarResponse.data as any)?.success) {
|
||||
throw new Error('更新头像失败');
|
||||
}
|
||||
}
|
||||
|
||||
await refetch();
|
||||
|
||||
onSave({
|
||||
name: trimmedName,
|
||||
avatar: avatarCandidate,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
Alert.alert('保存失败', error instanceof Error ? error.message : '保存用户信息失败,请稍后重试');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [avatarCandidate, hasNameChanged, hasAvatarChanged, onClose, onSave, trimmedName, refetch]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="slide"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button" />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.avoider}
|
||||
>
|
||||
<View style={[styles.card, { backgroundColor: palette.surface, borderColor: palette.frame }]}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close profile editor"
|
||||
onPress={onClose}
|
||||
style={[styles.closeButton, { backgroundColor: palette.buttonGhost, borderColor: palette.frame }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="close" size={18} color={palette.muted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.avatarSection}>
|
||||
<View
|
||||
style={[
|
||||
styles.avatarShell,
|
||||
{ backgroundColor: palette.avatarBackdrop, borderColor: palette.frame },
|
||||
]}
|
||||
>
|
||||
{avatarCandidate ? (
|
||||
<Image source={avatarCandidate} style={styles.avatarImage} resizeMode="cover" />
|
||||
) : (
|
||||
<ThemedText style={[styles.avatarInitials, { color: palette.primary }]}>
|
||||
{deriveInitials(trimmedName || initialName)}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose a new profile picture"
|
||||
onPress={handlePickAvatar}
|
||||
disabled={isUploading}
|
||||
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator size="small" color={palette.onAccent} />
|
||||
) : (
|
||||
<MaterialIcons name="photo-camera" size={18} color={palette.onAccent} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ThemedText style={[styles.modalTitle, { color: palette.primary }]}>
|
||||
Edit Profile
|
||||
</ThemedText>
|
||||
|
||||
<View style={[styles.fieldWrapper, { backgroundColor: palette.field, borderColor: palette.frame }]}>
|
||||
<TextInput
|
||||
value={nameValue}
|
||||
onChangeText={setNameValue}
|
||||
placeholder="Display name"
|
||||
placeholderTextColor={palette.placeholder}
|
||||
style={[styles.textInput, { color: palette.primary }]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save profile changes"
|
||||
onPress={handleSave}
|
||||
disabled={isSaveDisabled || isSaving}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{
|
||||
backgroundColor: (isSaveDisabled || isSaving) ? palette.buttonGhost : palette.accent,
|
||||
borderColor: (isSaveDisabled || isSaving) ? palette.frame : 'transparent',
|
||||
borderWidth: (isSaveDisabled || isSaving) ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color={palette.onAccent} />
|
||||
) : (
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.saveLabel,
|
||||
{ color: isSaveDisabled ? palette.muted : palette.onAccent },
|
||||
]}
|
||||
>
|
||||
Save
|
||||
</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const palettes = {
|
||||
dark: {
|
||||
surface: '#121216',
|
||||
frame: '#1D1E24',
|
||||
avatarBackdrop: '#1F2026',
|
||||
field: '#18181D',
|
||||
primary: '#F6F7FA',
|
||||
muted: '#8E9098',
|
||||
placeholder: '#5B5D66',
|
||||
accent: '#D1FE17',
|
||||
onAccent: '#050505',
|
||||
buttonGhost: '#101014',
|
||||
},
|
||||
light: {
|
||||
surface: '#FFFFFF',
|
||||
frame: '#D8DCE7',
|
||||
avatarBackdrop: '#E2E5EF',
|
||||
field: '#F3F5FC',
|
||||
primary: '#0F1320',
|
||||
muted: '#5E6474',
|
||||
placeholder: '#8C92A3',
|
||||
accent: '#405CFF',
|
||||
onAccent: '#FFFFFF',
|
||||
buttonGhost: '#EBEEF6',
|
||||
},
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5,5,5,0.68)',
|
||||
},
|
||||
avoider: {
|
||||
width: '100%',
|
||||
},
|
||||
card: {
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 28,
|
||||
borderWidth: 1,
|
||||
borderBottomWidth: 0,
|
||||
width: '100%',
|
||||
},
|
||||
closeButton: {
|
||||
alignSelf: 'flex-end',
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarShell: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
avatarInitials: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cameraButton: {
|
||||
position: 'absolute',
|
||||
right: 18,
|
||||
bottom: 6,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
fieldWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
saveButton: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
saveLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
function getSourceKey(source?: ImageSourcePropType): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (typeof source === 'number') {
|
||||
return String(source);
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return source.map(getSourceKey).join('|');
|
||||
}
|
||||
if ('uri' in source && source.uri) {
|
||||
return source.uri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
114
components/profile/profile-empty-state.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
|
||||
export function ProfileEmptyState({ activeTab }: { activeTab: TabKey }) {
|
||||
const palette = darkPalette;
|
||||
const copy = deriveEmptyCopy(activeTab);
|
||||
|
||||
return (
|
||||
<View style={styles.emptyWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.emptyGlyph,
|
||||
{
|
||||
backgroundColor: palette.glyphBackdrop,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="image" size={40} color={palette.accent} />
|
||||
</View>
|
||||
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>{copy.title}</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>{copy.subtitle}</ThemedText>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push('/(tabs)/home')}
|
||||
style={[
|
||||
styles.generateButton,
|
||||
{
|
||||
borderColor: palette.accent,
|
||||
backgroundColor: palette.elevated,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={18} color={palette.accent} style={styles.generateIcon} />
|
||||
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Generate</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveEmptyCopy(activeTab: TabKey) {
|
||||
if (activeTab === 'image') {
|
||||
return {
|
||||
title: 'No Images yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
||||
};
|
||||
}
|
||||
if (activeTab === 'video') {
|
||||
return {
|
||||
title: 'No Videos yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and craft your first clip.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No Content yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
||||
};
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
glyphBackdrop: '#18181D',
|
||||
border: '#1D1E24',
|
||||
accent: '#B7FF2F',
|
||||
elevated: '#101014',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
emptyWrap: {
|
||||
alignItems: 'center' as const,
|
||||
paddingTop: 32,
|
||||
},
|
||||
emptyGlyph: {
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700' as const,
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
generateButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
generateIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700' as const,
|
||||
},
|
||||
};
|
||||
104
components/profile/profile-error-state.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
export function ProfileErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
const palette = darkPalette;
|
||||
|
||||
return (
|
||||
<View style={styles.emptyWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.emptyGlyph,
|
||||
{
|
||||
backgroundColor: palette.glyphBackdrop,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="error-outline" size={40} color={palette.textSecondary} />
|
||||
</View>
|
||||
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>Failed to load</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>
|
||||
There was an error loading your content
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onRetry}
|
||||
style={[
|
||||
styles.generateButton,
|
||||
{
|
||||
borderColor: palette.accent,
|
||||
backgroundColor: palette.elevated,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="refresh" size={18} color={palette.accent} style={styles.generateIcon} />
|
||||
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Retry</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
glyphBackdrop: '#18181D',
|
||||
border: '#1D1E24',
|
||||
accent: '#B7FF2F',
|
||||
elevated: '#101014',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
glyphBackdrop: '#E8EBF4',
|
||||
border: '#E2E5ED',
|
||||
accent: '#405CFF',
|
||||
elevated: '#F0F2F8',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
emptyWrap: {
|
||||
alignItems: 'center' as const,
|
||||
paddingTop: 32,
|
||||
},
|
||||
emptyGlyph: {
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700' as const,
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
generateButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
generateIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700' as const,
|
||||
},
|
||||
};
|
||||