mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 700ed2b0bb | |||
| 3349b6ad44 | |||
| 2191a83075 | |||
| b584985767 | |||
| df78753ab3 | |||
| 21fded5d95 | |||
| 99411fd48e | |||
| ea82260edb | |||
| d96cd6045f | |||
| bc2903a6e5 | |||
| 96f4e12347 | |||
| 3affb884dd | |||
| 61ae82fca8 | |||
| 81ab5168c4 | |||
| 23a1efff46 | |||
| ee0b1775a1 | |||
| 3e1280a697 | |||
| d0ef24e2c5 | |||
| 046a0d2bd7 | |||
| b65ffdb39d | |||
| 9146d1d0a4 | |||
| c75c5d0c4c | |||
| 6af5790f3d | |||
| 0ac4857091 | |||
| 42cdd46424 | |||
| 1df0e65f8d | |||
| 7794a077da | |||
| 3853e36717 | |||
| 4aa767f576 | |||
| 56104c9315 | |||
| 688ce5fadf | |||
| fbaeac2d1e | |||
| 7ed1b00496 | |||
| 08773dbc03 | |||
| 0361c05a8f | |||
| c90e92c5f1 | |||
| 065c38273a | |||
| 4753fe5d6d | |||
| f14f09abe3 | |||
| c117d077d6 | |||
| 1bfbe451fa | |||
| 1c450b22f6 | |||
| e438d0dcd8 | |||
| 3de309c37a | |||
| a13f19d87a | |||
| 0b7e30bd22 | |||
| a1bd36a367 | |||
| d0382ab4a1 | |||
| 3fb71d28b9 | |||
| 7d9b963790 | |||
| 40d1bdc98c | |||
| 0b0220ea51 | |||
| 6fee0f1a6e | |||
| 88f1a91761 | |||
| 709fd293a6 | |||
| 50b7189061 | |||
| 7b32c1eee3 | |||
| cce81be013 | |||
| 6c1174427b | |||
| c9eb5af95c | |||
| 301ecbd457 | |||
| d7d6aa827a | |||
| 77fae1cf97 | |||
| 497033e17d | |||
| 854f8903cf | |||
| 9c295b79f1 | |||
| c40a1a6329 | |||
| 7dfbccfa55 | |||
| bf434c73b5 | |||
| 1a63bfd258 | |||
| a02960d928 | |||
| 9637ce846e | |||
| dfb2ab5f8c | |||
| 3e0a0bd184 | |||
| 81c36dedb7 | |||
| affa02eb26 | |||
| ae365ef6be | |||
| 8eab665505 | |||
| 3a45a383eb | |||
| b4126686ac | |||
| 6c1964bf83 | |||
| 97728ca1e8 | |||
| 9c05b13845 | |||
| 2cadc12047 | |||
| c8c46a0ef6 | |||
| 140c8ecccf | |||
| 16cfa5dfed | |||
| 80569aebfd | |||
| 3104122bf3 | |||
| d424093366 | |||
| 992b46695d | |||
| 8f1bdbc3f1 | |||
| 6ec7d5feca | |||
| e4051fa8dc | |||
| af110931b4 | |||
| 1e0d0a5337 | |||
| 4d7d26fe12 | |||
| bf3b57b4ef | |||
| 1ffeb7e92b | |||
| a1e822b543 | |||
| 0785119aa6 | |||
| 75aca0f2b8 |
@@ -0,0 +1,7 @@
|
||||
# copy this file to .env when you want to develop the desktop app or you will fail
|
||||
APP_URL=http://localhost:3020
|
||||
KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
|
||||
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
|
||||
NEXT_PUBLIC_SERVICE_MODE='server'
|
||||
NEXT_PUBLIC_IS_DESKTOP_APP=0
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0
|
||||
@@ -117,3 +117,12 @@ CLAUDE.local.md
|
||||
|
||||
prd
|
||||
GEMINI.md
|
||||
|
||||
|
||||
# for local prd docs
|
||||
docs/prd
|
||||
|
||||
# eas
|
||||
service-account-key.json
|
||||
|
||||
repomix-output-lobehub-lobe-ui.xml
|
||||
|
||||
@@ -3,6 +3,7 @@ resolution-mode=highest
|
||||
|
||||
ignore-workspace-root-check=true
|
||||
enable-pre-post-scripts=true
|
||||
strict-store-pkg-content-check=false
|
||||
|
||||
# Load dotenv files for all the npm scripts
|
||||
node-options="--require dotenv-expand/config"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Create development builds
|
||||
|
||||
jobs:
|
||||
android_development_build:
|
||||
name: Build Android
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: development
|
||||
ios_device_development_build:
|
||||
name: Build iOS device
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development
|
||||
ios_simulator_development_build:
|
||||
name: Build iOS simulator
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development-simulator
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Create Production Builds
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
type: build # This job type creates a production build for Android
|
||||
params:
|
||||
platform: android
|
||||
build_ios:
|
||||
type: build # This job type creates a production build for iOS
|
||||
params:
|
||||
platform: ios
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Deploy to production
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
fingerprint:
|
||||
name: Fingerprint
|
||||
type: fingerprint
|
||||
get_android_build:
|
||||
name: Check for existing android build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
|
||||
profile: production
|
||||
get_ios_build:
|
||||
name: Check for existing ios build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
|
||||
profile: production
|
||||
build_android:
|
||||
name: Build Android
|
||||
needs: [get_android_build]
|
||||
if: ${{ !needs.get_android_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
build_ios:
|
||||
name: Build iOS
|
||||
needs: [get_ios_build]
|
||||
if: ${{ !needs.get_ios_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
submit_android_build:
|
||||
name: Submit Android Build
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
submit_ios_build:
|
||||
name: Submit iOS Build
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
publish_android_update:
|
||||
name: Publish Android update
|
||||
needs: [get_android_build]
|
||||
if: ${{ needs.get_android_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: android
|
||||
publish_ios_update:
|
||||
name: Publish iOS update
|
||||
needs: [get_ios_build]
|
||||
if: ${{ needs.get_ios_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: ios
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Publish preview update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
|
||||
jobs:
|
||||
publish_preview_update:
|
||||
name: Publish preview update
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
name: Build Android app
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
submit_android:
|
||||
name: Submit to Google Play Store
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
platform: android
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_ios:
|
||||
name: Build iOS app
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit_ios:
|
||||
name: Submit to Apple App Store
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
platform: ios
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
@@ -0,0 +1,9 @@
|
||||
# OAuth 2.0 OIDC 认证配置
|
||||
# 客户端 ID - 从认证服务器获取
|
||||
EXPO_PUBLIC_OAUTH_CLIENT_ID=lobehub-mobile
|
||||
|
||||
# 官方云服务器地址
|
||||
EXPO_PUBLIC_OFFICIAL_CLOUD_SERVER=https://lobechat.com
|
||||
|
||||
# 回调地址 - 必须与认证服务器配置一致
|
||||
EXPO_PUBLIC_OAUTH_REDIRECT_URI=com.lobehub.app://auth/callback
|
||||
@@ -0,0 +1,36 @@
|
||||
# Eslintignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
|
||||
# test
|
||||
jest*
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
|
||||
# umi
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
!.dumirc.ts
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
ios
|
||||
android
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
.expo
|
||||
|
||||
polyfills.ts
|
||||
temp
|
||||
@@ -0,0 +1,55 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
package-lock.json
|
||||
# pnpm-lock.yaml
|
||||
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
android
|
||||
ios
|
||||
yarn.lock
|
||||
repomix-output.xml
|
||||
lobe-ui-repomix-output.xml
|
||||
lobe-chat-repomix-output.xml
|
||||
repomix-output-ant-design-theme.xml
|
||||
credentials.json
|
||||
|
||||
build-*.aab
|
||||
build-*.apk
|
||||
build-*.ipa
|
||||
|
||||
coverage
|
||||
@@ -0,0 +1,50 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'locales/zh-CN',
|
||||
entryLocale: 'zh-CN',
|
||||
output: 'locales',
|
||||
outputLocales: [
|
||||
'ar',
|
||||
'bg-BG',
|
||||
'zh-TW',
|
||||
'en-US',
|
||||
'ru-RU',
|
||||
'ja-JP',
|
||||
'ko-KR',
|
||||
'fr-FR',
|
||||
'tr-TR',
|
||||
'es-ES',
|
||||
'pt-BR',
|
||||
'de-DE',
|
||||
'it-IT',
|
||||
'nl-NL',
|
||||
'pl-PL',
|
||||
'vi-VN',
|
||||
'fa-IR',
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
// chatgpt-4o-latest 和 gpt-chat 翻译效果更好
|
||||
modelName: 'gpt-4.1-mini',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
markdown: {
|
||||
reference: '你需要保持 mdx 的组件格式,输出文本不需要在最外层包裹任何代码块语法',
|
||||
entry: ['./README.zh-CN.md'],
|
||||
entryLocale: 'zh-CN',
|
||||
outputLocales: ['en-US'],
|
||||
includeMatter: true,
|
||||
exclude: ['./src/**/*'],
|
||||
outputExtensions: (locale, { filePath }) => {
|
||||
if (filePath.includes('.mdx')) {
|
||||
if (locale === 'en-US') return '.mdx';
|
||||
return `.${locale}.mdx`;
|
||||
} else {
|
||||
if (locale === 'en-US') return '.md';
|
||||
return `.${locale}.md`;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
lockfile=true
|
||||
node-linker=hoisted
|
||||
|
||||
enable-pre-post-scripts=true
|
||||
|
||||
# Load dotenv files for all the npm scripts
|
||||
node-options="--require dotenv-expand/config"
|
||||
|
||||
public-hoist-pattern[]=*@umijs/lint*
|
||||
public-hoist-pattern[]=*changelog*
|
||||
public-hoist-pattern[]=*commitlint*
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=*postcss*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
public-hoist-pattern[]=*remark*
|
||||
public-hoist-pattern[]=*semantic-release*
|
||||
public-hoist-pattern[]=*stylelint*
|
||||
|
||||
public-hoist-pattern[]=@auth/core
|
||||
public-hoist-pattern[]=@clerk/backend
|
||||
public-hoist-pattern[]=@clerk/types
|
||||
public-hoist-pattern[]=pdfjs-dist
|
||||
@@ -0,0 +1,67 @@
|
||||
# Prettierignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# general
|
||||
.DS_Store
|
||||
.editorconfig
|
||||
.idea
|
||||
.vscode
|
||||
.history
|
||||
.temp
|
||||
.env.local
|
||||
.husky
|
||||
.npmrc
|
||||
.gitkeep
|
||||
venv
|
||||
temp
|
||||
tmp
|
||||
LICENSE
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
*.log
|
||||
*.lock
|
||||
package-lock.json
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
test-output
|
||||
__snapshots__
|
||||
*.snap
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
|
||||
# umi
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
|
||||
# ignore files
|
||||
.*ignore
|
||||
|
||||
# docker
|
||||
docker
|
||||
Dockerfile*
|
||||
|
||||
# image
|
||||
*.webp
|
||||
*.gif
|
||||
*.png
|
||||
*.jpg
|
||||
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
./src/icons/MaterialFileTypeIcon/icon-map.json
|
||||
*.ico
|
||||
*.backup
|
||||
*.mdc
|
||||
*.ttf
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').prettier;
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').remarklint;
|
||||
@@ -0,0 +1,10 @@
|
||||
const config = require('@lobehub/lint').stylelint;
|
||||
|
||||
module.exports = {
|
||||
...config,
|
||||
rules: {
|
||||
'custom-property-pattern': null,
|
||||
'no-descending-specificity': null,
|
||||
...config.rules,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
# LobeChat Mobile Agent Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
- React Native + Expo app delivering the LobeChat AI chat experience for iOS and Android from a shared codebase.
|
||||
- Core entry points: `app/_layout.tsx` wires global providers, while `app/index.tsx` (and nested routes under `app/(main)`) drive navigation via Expo Router.
|
||||
- State flows through colocated Zustand stores such as `store/chat`, `store/session`, and `store/openai`; selectors in `store/session/selectors` keep components efficient.
|
||||
- Key platform features include Markdown + math rendering (React Native Markdown, Shiki, MathJax), streaming chat transport via `utils/fetchSSE`, and tRPC clients configured in `utils/trpc`.
|
||||
- Follow the domain guides in `rules/` (e.g., `project-overview.mdc`, `state-management.mdc`, `api-integration.mdc`) for deeper architecture context before implementing changes.
|
||||
|
||||
## Build and Test Commands
|
||||
|
||||
| Task | Command | Notes |
|
||||
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Install dependencies | `pnpm install` | Requires Node 18+ and pnpm 8+. |
|
||||
| Start Expo dev server | `pnpm start` | Opens the Metro bundler with QR code pairing. |
|
||||
| Run on iOS simulator/device | `pnpm ios` / `pnpm device:ios` | Uses `expo run:ios`; ensure Xcode tooling is installed. |
|
||||
| Run on Android emulator/device | `pnpm android` / `pnpm device:android` | Uses `expo run:android`; start an emulator first. |
|
||||
| Web preview | `pnpm web` | Launches the web bundle for quick UI checks. |
|
||||
| Jest unit tests | `pnpm test` | Runs through `jest-expo` with coverage enabled. |
|
||||
| Lint TypeScript/JS | `pnpm lint` | Delegates to Expo + ESLint rules defined in repo. |
|
||||
| Format sources | `pnpm prettier` | Applies Prettier across the workspace. |
|
||||
| Generate translations | `pnpm i18n` | Executes scripted workflow from `rules/internationalization.mdc`. |
|
||||
| Production builds | `pnpm production:ios` / `pnpm production:android` | Wraps EAS build profiles from `eas.json`. |
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- TypeScript runs in strict mode; define interfaces/types for component props and store state, avoiding `any`. Reference `rules/app-structure.mdc` and `rules/state-management.mdc` for patterns.
|
||||
- File naming: Components in PascalCase, hooks in camelCase prefixed with `use`, utilities in camelCase, constants in UPPER_SNAKE_CASE (see `rules/development-workflow.mdc`).
|
||||
- Use absolute imports with the `@/` alias; group imports by React, third-party, then internal modules.
|
||||
- Keep UI logic declarative; colocate styles in `styles.ts` companions and respect theming conventions from `rules/color-system.mdc`.
|
||||
- Run `pnpm lint` and `pnpm prettier` before committing. Git hooks enforce Conventional Commits and formatting, so align commit messages with `feat|fix|chore` etc.
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
- Place tests under `test/` or alongside components as `*.test.ts(x)` per `rules/react-native.mdc` guidance.
|
||||
- Use `@testing-library/react-native` for rendering and interaction assertions; rely on user-centered queries rather than implementation details.
|
||||
- Mock async integrations (SecureStore, AsyncStorage, network services) using Jest mocks; reference `rules/debug-usage.mdc` for logging utilities during tests.
|
||||
- Prefer explicit assertions over brittle snapshots; when snapshots are required, keep them under `__snapshots__` directories.
|
||||
- Run `pnpm test --watch` during iteration and ensure `pnpm test` + `pnpm lint` pass before opening a PR.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Keep provider keys in `.env.local` (copied from `.env.example`) and load them through the secure configuration flows in `store/openai`; never commit secrets.
|
||||
- Persist sensitive tokens with `expo-secure-store`; avoid plain AsyncStorage for credentials as outlined in `rules/api-integration.mdc`.
|
||||
- Validate API responses and sanitize Markdown before render—Shiki/remark plugins are already configured, but keep dependencies patched.
|
||||
- Enforce HTTPS endpoints for remote calls; review EAS credentials and signing configs before production builds.
|
||||
- Monitor dependency upgrades touching auth libraries (`expo-auth-session`, `jose`, `jwt-decode`) and align with guidance in `rules/development-workflow.mdc`.
|
||||
|
||||
## Additional References
|
||||
|
||||
- `rules/quick-reference.mdc` for command cheatsheets.
|
||||
- `rules/internationalization.mdc` to extend locale coverage.
|
||||
- `rules/debug-usage.mdc` for logging and diagnostics standards.
|
||||
@@ -0,0 +1,15 @@
|
||||
Apache License Version 2.0
|
||||
|
||||
Copyright (c) 2025/06/17 - current LobeHub LLC. All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,202 @@
|
||||
# LobeChat React Native
|
||||
|
||||
A modern open-source AI chat application built with React Native\
|
||||
Get your own cross-platform ChatGPT/Claude/Gemini app for **free** with one click
|
||||
|
||||
**简体中文** · [English](./README.md)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/releases) [](https://expo.dev) [](https://reactnative.dev) [](https://www.typescriptlang.org)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/stargazers) [](https://github.com/lobehub/lobe-chat-react-native/network/members) [](https://github.com/lobehub/lobe-chat-react-native/issues) [](https://github.com/lobehub/lobe-chat-react-native/blob/main/LICENSE)
|
||||
|
||||
Explore the limitless possibilities of AI conversations on mobile, crafted for you in an era of individual empowerment.
|
||||
|
||||

|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [✨ Features Overview](#-features-overview)
|
||||
- [📱 Supported Platforms](#-supported-platforms)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
- [🛠️ Technology Stack](#️-technology-stack)
|
||||
- [📦 Project Structure](#-project-structure)
|
||||
- [⌨️ Local Development](#️-local-development)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [🔗 More Tools](#-more-tools)
|
||||
|
||||
## ✨ Features Overview
|
||||
|
||||
### `1` Cross-Platform Support
|
||||
|
||||
Built with React Native and Expo, fully supporting both iOS and Android platforms with a single codebase running on multiple devices.
|
||||
|
||||
### `2` Modern UI Design
|
||||
|
||||
- 💎 **Refined UI**: Carefully crafted interface with elegant visuals and smooth interactions
|
||||
- 🌗 **Dark/Light Themes**: Supports theme switching and adapts to system preferences
|
||||
- 📱 **Mobile Optimization**: Deeply optimized for mobile devices, delivering a native app-like experience
|
||||
|
||||
### `3` Multi-Model Provider Support
|
||||
|
||||
Supports a variety of mainstream AI service providers:
|
||||
|
||||
- **OpenAI**: GPT-4, GPT-3.5, and more
|
||||
- **Anthropic**: Claude series models
|
||||
- **Google**: Gemini series models
|
||||
- **Local Models**: Supports local LLMs like Ollama
|
||||
|
||||
### `4` Powerful Conversation Features
|
||||
|
||||
- 🗣️ **Smooth Chat Experience**: Supports streaming responses for real-time AI replies
|
||||
- 📝 **Markdown Rendering**: Full support for Markdown formatting, including code highlighting
|
||||
- 🎨 **Code Syntax Highlighting**: Professional code rendering powered by Shiki
|
||||
- 🔊 **Voice Interaction**: Supports text-to-speech and speech-to-text functionality
|
||||
|
||||
### `5` Security and Privacy
|
||||
|
||||
- 🔒 **Data Security**: Supports local data storage to protect user privacy
|
||||
- 💾 **Offline Support**: Important data cached locally so you can view chat history offline
|
||||
|
||||
### `6` Developer Friendly
|
||||
|
||||
- 🛠️ **TypeScript**: Full type support for a better development experience
|
||||
- 📦 **Modular Architecture**: Clear project structure for easy maintenance and extensibility
|
||||
- 🧪 **Testing Support**: Built-in Jest testing framework
|
||||
- 📱 **Hot Reloading**: Real-time preview during development
|
||||
|
||||
## 📱 Supported Platforms
|
||||
|
||||
| Platform | Status | Version Requirement |
|
||||
| -------- | ------------ | --------------------- |
|
||||
| iOS | ✅ Supported | iOS 13.4+ |
|
||||
| Android | ✅ Supported | Android 6.0+ (API 23) |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Environment Requirements
|
||||
|
||||
Before getting started, please ensure your development environment meets the following requirements:
|
||||
|
||||
- **Node.js**: >= 18.0.0
|
||||
- **pnpm**: >= 8.0.0 (recommended)
|
||||
- **Expo CLI**: Latest version
|
||||
- **iOS**: Xcode 14+ (macOS only)
|
||||
- **Android**: Android Studio
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/lobehub/lobe-chat-react-native.git
|
||||
cd lobe-chat-react-native
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Configure Environment Variables
|
||||
|
||||
```bash
|
||||
# Copy the environment variable template
|
||||
cp .env.example .env.local
|
||||
|
||||
# Edit the environment variables and add your API keys
|
||||
# OPENAI_API_KEY=your_openai_api_key
|
||||
```
|
||||
|
||||
### Start the Development Server
|
||||
|
||||
```bash
|
||||
# Start the Expo development server
|
||||
pnpm start
|
||||
|
||||
# Or run on a specific platform directly
|
||||
pnpm run ios # iOS simulator
|
||||
pnpm run android # Android emulator
|
||||
```
|
||||
|
||||
### Run on a Physical Device
|
||||
|
||||
1. Install the **Expo Go** app:
|
||||
- [iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
|
||||
- [Google Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
|
||||
|
||||
2. Scan the QR code displayed in the terminal to preview on your device
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
| Technology | Version | Description |
|
||||
| --------------------------- | -------- | ----------------------------------------------- |
|
||||
| **React Native** | 0.76.6 | Cross-platform mobile app framework |
|
||||
| **Expo** | \~52.0.5 | React Native development platform and toolchain |
|
||||
| **TypeScript** | ^5.8.2 | Type-safe JavaScript superset |
|
||||
| **Expo Router** | \~4.0.17 | File system-based routing solution |
|
||||
| **Zustand** | ^5.0.3 | Lightweight state management library |
|
||||
| **React Native Reanimated** | \~3.16.7 | High-performance animation library |
|
||||
| **Shiki** | ^3.1.0 | Code syntax highlighting engine |
|
||||
|
||||
## ⌨️ Local Development
|
||||
|
||||
### Development Scripts
|
||||
|
||||
```bash
|
||||
# Start the development server
|
||||
pnpm start
|
||||
|
||||
# Run on iOS simulator
|
||||
pnpm run ios
|
||||
|
||||
# Run on Android emulator
|
||||
pnpm run android
|
||||
|
||||
# Run tests
|
||||
pnpm run test
|
||||
|
||||
# Code linting
|
||||
pnpm run lint
|
||||
|
||||
# Build the app
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Code Standards
|
||||
|
||||
This project follows strict code standards:
|
||||
|
||||
- **ESLint** + **Prettier** for code formatting
|
||||
- **TypeScript** strict type checking
|
||||
- **Git Hooks** for pre-commit checks
|
||||
- **Conventional Commits** for commit message conventions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We warmly welcome all forms of contributions!
|
||||
|
||||
### Contribution Guide
|
||||
|
||||
1. **Fork** this repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a **Pull Request**
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
- Please follow the [Conventional Commits](https://conventionalcommits.org/) specification for commit messages
|
||||
- Use Prettier for code formatting and ESLint for code linting
|
||||
- All new features should include corresponding test cases
|
||||
|
||||
## 🔗 More Tools
|
||||
|
||||
| Project | Description |
|
||||
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| **[🤯 Lobe Chat](https://github.com/lobehub/lobe-chat)** | Modern open-source ChatGPT/LLM chat app (Web version) |
|
||||
| **[🅰️ Lobe UI](https://github.com/lobehub/lobe-ui)** | Open-source UI component library for building AIGC web apps |
|
||||
| **[🌏 Lobe i18n](https://github.com/lobehub/lobe-commit/tree/master/packages/lobe-i18n)** | ChatGPT-powered i18n translation automation tool |
|
||||
| **[💌 Lobe Commit](https://github.com/lobehub/lobe-commit)** | AI-based Git commit message generator |
|
||||
|
||||
---
|
||||
|
||||
#### 📝 License
|
||||
|
||||
Copyright © 2025 [LobeHub](https://github.com/lobehub). This project is open source under the [Apache 2.0](./LICENSE) license.
|
||||
@@ -0,0 +1,202 @@
|
||||
# LobeChat React Native
|
||||
|
||||
现代化设计的开源 AI 聊天应用,基于 React Native 构建\
|
||||
一键**免费**拥有你自己的跨平台 ChatGPT/Claude/Gemini 应用
|
||||
|
||||
**简体中文** · [English](./README.md)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/releases) [](https://expo.dev) [](https://reactnative.dev) [](https://www.typescriptlang.org)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/stargazers) [](https://github.com/lobehub/lobe-chat-react-native/network/members) [](https://github.com/lobehub/lobe-chat-react-native/issues) [](https://github.com/lobehub/lobe-chat-react-native/blob/main/LICENSE)
|
||||
|
||||
探索移动端 AI 对话的无限可能,在个体崛起的时代中为你打造
|
||||
|
||||

|
||||
|
||||
## 目录
|
||||
|
||||
- [✨ 特性一览](#-特性一览)
|
||||
- [📱 支持平台](#-支持平台)
|
||||
- [🚀 快速开始](#-快速开始)
|
||||
- [🛠️ 技术栈](#️-技术栈)
|
||||
- [📦 项目结构](#-项目结构)
|
||||
- [⌨️ 本地开发](#️-本地开发)
|
||||
- [🤝 参与贡献](#-参与贡献)
|
||||
- [🔗 更多工具](#-更多工具)
|
||||
|
||||
## ✨ 特性一览
|
||||
|
||||
### `1` 跨平台支持
|
||||
|
||||
基于 React Native 和 Expo 构建,完美支持 iOS 和 Android 平台,一套代码多端运行。
|
||||
|
||||
### `2` 现代化 UI 设计
|
||||
|
||||
- 💎 **精致 UI 设计**:经过精心设计的界面,具有优雅的外观和流畅的交互效果
|
||||
- 🌗 **深色 / 浅色主题**:支持明暗主题切换,适配系统主题
|
||||
- 📱 **移动端优化**:针对移动设备进行了深度优化,提供原生应用般的体验
|
||||
|
||||
### `3` 多模型服务商支持
|
||||
|
||||
支持多种主流 AI 服务提供商:
|
||||
|
||||
- **OpenAI**:GPT-4、GPT-3.5 等模型
|
||||
- **Anthropic**:Claude 系列模型
|
||||
- **Google**:Gemini 系列模型
|
||||
- **本地模型**:支持 Ollama 等本地 LLM
|
||||
|
||||
### `4` 强大的会话功能
|
||||
|
||||
- 🗣️ **流畅的对话体验**:支持流式响应,实时显示 AI 回复
|
||||
- 📝 **Markdown 渲染**:完整支持 Markdown 格式,包括代码高亮
|
||||
- 🎨 **代码语法高亮**:基于 Shiki 的专业代码渲染
|
||||
- 🔊 **语音交互**:支持文字转语音和语音转文字功能
|
||||
|
||||
### `5` 安全与隐私
|
||||
|
||||
- 🔒 **数据安全**:支持本地数据存储,保护用户隐私
|
||||
- 💾 **离线支持**:重要数据本地缓存,离线也能查看历史对话
|
||||
|
||||
### `6` 开发者友好
|
||||
|
||||
- 🛠️ **TypeScript**:完整的类型支持,提供更好的开发体验
|
||||
- 📦 **模块化架构**:清晰的项目结构,易于维护和扩展
|
||||
- 🧪 **测试支持**:内置 Jest 测试框架
|
||||
- 📱 **热重载**:开发过程中支持实时预览
|
||||
|
||||
## 📱 支持平台
|
||||
|
||||
| 平台 | 状态 | 版本要求 |
|
||||
| ------- | ------- | --------------------- |
|
||||
| iOS | ✅ 支持 | iOS 13.4+ |
|
||||
| Android | ✅ 支持 | Android 6.0+ (API 23) |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
在开始之前,请确保你的开发环境满足以下要求:
|
||||
|
||||
- **Node.js**: >= 18.0.0
|
||||
- **pnpm**: >= 8.0.0(推荐)
|
||||
- **Expo CLI**: 最新版本
|
||||
- **iOS**: Xcode 14+ (仅限 macOS)
|
||||
- **Android**: Android Studio
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/lobehub/lobe-chat-react-native.git
|
||||
cd lobe-chat-react-native
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env.local
|
||||
|
||||
# 编辑环境变量,填入你的 API 密钥
|
||||
# OPENAI_API_KEY=your_openai_api_key
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 启动 Expo 开发服务器
|
||||
pnpm start
|
||||
|
||||
# 或者直接运行指定平台
|
||||
pnpm run ios # iOS 模拟器
|
||||
pnpm run android # Android 模拟器
|
||||
```
|
||||
|
||||
### 在设备上运行
|
||||
|
||||
1. 安装 **Expo Go** 应用:
|
||||
- [iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
|
||||
- [Google Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
|
||||
|
||||
2. 扫描终端中显示的二维码即可在真机上预览
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 技术 | 版本 | 描述 |
|
||||
| --------------------------- | -------- | ----------------------------- |
|
||||
| **React Native** | 0.76.6 | 跨平台移动应用开发框架 |
|
||||
| **Expo** | \~52.0.5 | React Native 开发平台和工具链 |
|
||||
| **TypeScript** | ^5.8.2 | 类型安全的 JavaScript 超集 |
|
||||
| **Expo Router** | \~4.0.17 | 基于文件系统的路由解决方案 |
|
||||
| **Zustand** | ^5.0.3 | 轻量级状态管理库 |
|
||||
| **React Native Reanimated** | \~3.16.7 | 高性能动画库 |
|
||||
| **Shiki** | ^3.1.0 | 代码语法高亮引擎 |
|
||||
|
||||
## ⌨️ 本地开发
|
||||
|
||||
### 开发脚本
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
pnpm start
|
||||
|
||||
# 在 iOS 模拟器中运行
|
||||
pnpm run ios
|
||||
|
||||
# 在 Android 模拟器中运行
|
||||
pnpm run android
|
||||
|
||||
# 运行测试
|
||||
pnpm run test
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 构建应用
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
本项目遵循严格的代码规范:
|
||||
|
||||
- **ESLint** + **Prettier** 代码格式化
|
||||
- **TypeScript** 严格类型检查
|
||||
- **Git Hooks** 提交前自动检查
|
||||
- **Conventional Commits** 提交信息规范
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
我们非常欢迎各种形式的贡献!
|
||||
|
||||
### 贡献指南
|
||||
|
||||
1. **Fork** 本仓库
|
||||
2. 创建你的特性分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交你的改动 (`git commit -m 'feat: add amazing feature'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 创建一个 **Pull Request**
|
||||
|
||||
### 开发规范
|
||||
|
||||
- 提交信息请遵循 [Conventional Commits](https://conventionalcommits.org/) 规范
|
||||
- 代码格式化使用 Prettier,代码检查使用 ESLint
|
||||
- 所有新功能都需要包含相应的测试用例
|
||||
|
||||
## 🔗 更多工具
|
||||
|
||||
| 项目 | 描述 |
|
||||
| ----------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| **[🤯 Lobe Chat](https://github.com/lobehub/lobe-chat)** | 现代化设计的开源 ChatGPT/LLM 聊天应用(Web 版) |
|
||||
| **[🅰️ Lobe UI](https://github.com/lobehub/lobe-ui)** | 构建 AIGC 网页应用的开源 UI 组件库 |
|
||||
| **[🌏 Lobe i18n](https://github.com/lobehub/lobe-commit/tree/master/packages/lobe-i18n)** | 由 ChatGPT 驱动的 i18n 翻译自动化工具 |
|
||||
| **[💌 Lobe Commit](https://github.com/lobehub/lobe-commit)** | 基于 AI 的 Git 提交信息生成工具 |
|
||||
|
||||
---
|
||||
|
||||
#### 📝 开源协议
|
||||
|
||||
Copyright © 2025 [LobeHub](https://github.com/lobehub). 本项目基于 [Apache 2.0](./LICENSE) 协议开源.
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "LobeHub",
|
||||
"slug": "lobe-chat-react-native",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "com.lobehub.app",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.lobehub.app",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
},
|
||||
"icon": {
|
||||
"dark": "./assets/images/ios-dark.png",
|
||||
"light": "./assets/images/ios-light.png",
|
||||
"tinted": "./assets/images/ios-tinted.png"
|
||||
},
|
||||
"appleTeamId": "4684H589ZU"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.lobehub.app"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.ico"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"./plugins/withFbjniFix",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon-light.png",
|
||||
"backgroundColor": "#ffffff",
|
||||
"imageWidth": 200,
|
||||
"dark": {
|
||||
"image": "./assets/images/splash-icon-dark.png",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"resizeMode": "contain"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"fonts": [
|
||||
"./assets/fonts/Hack-Regular.ttf",
|
||||
"./assets/fonts/Hack-Bold.ttf",
|
||||
"./assets/fonts/Hack-Italic.ttf",
|
||||
"./assets/fonts/Hack-BoldItalic.ttf",
|
||||
"./assets/fonts/HarmonyOS_Sans_Regular.ttf",
|
||||
"./assets/fonts/HarmonyOS_Sans_Medium.ttf",
|
||||
"./assets/fonts/HarmonyOS_Sans_Bold.ttf",
|
||||
"./assets/fonts/HarmonyOS_Sans_SC_Regular.ttf",
|
||||
"./assets/fonts/HarmonyOS_Sans_SC_Medium.ttf",
|
||||
"./assets/fonts/HarmonyOS_Sans_SC_Bold.ttf"
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"fontFamily": "Hack",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "./assets/fonts/Hack-Regular.ttf",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/Hack-Bold.ttf",
|
||||
"weight": 700
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/Hack-Italic.ttf",
|
||||
"weight": 400,
|
||||
"style": "italic"
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/Hack-BoldItalic.ttf",
|
||||
"weight": 700,
|
||||
"style": "italic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "HarmonyOS-Sans",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "./assets/fonts/HarmonyOS_Sans_Regular.ttf",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/HarmonyOS_Sans_Medium.ttf",
|
||||
"weight": 500
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/HarmonyOS_Sans_Bold.ttf",
|
||||
"weight": 700
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "HarmonyOS-Sans-SC",
|
||||
"fontDefinitions": [
|
||||
{
|
||||
"path": "./assets/fonts/HarmonyOS_Sans_SC_Regular.ttf",
|
||||
"weight": 400
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/HarmonyOS_Sans_SC_Medium.ttf",
|
||||
"weight": 500
|
||||
},
|
||||
{
|
||||
"path": "./assets/fonts/HarmonyOS_Sans_SC_Bold.ttf",
|
||||
"weight": 700
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expo-secure-store",
|
||||
"expo-localization"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "f02d6f4f-e042-4c95-ba0d-ac06bb474ef0"
|
||||
}
|
||||
},
|
||||
"owner": "lobehub",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/f02d6f4f-e042-4c95-ba0d-ac06bb474ef0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useThemedScreenOptions } from '@/_const/navigation';
|
||||
|
||||
export default function ChatRoutesLayout() {
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...themedScreenOptions,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="setting/index" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ActionIcon, Avatar, PageContainer, Space } from '@lobehub/ui-rn';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AlignJustify, MoreHorizontal } from 'lucide-react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View } from 'react-native';
|
||||
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
|
||||
|
||||
import { AVATAR_SIZE } from '@/_const/common';
|
||||
import Hydration from '@/features/Hydration';
|
||||
import SideBar from '@/features/SideBar';
|
||||
import TopicDrawer from '@/features/TopicDrawer';
|
||||
import ChatInput from '@/features/chat/ChatInput';
|
||||
import ChatList from '@/features/chat/ChatList';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
export default function ChatWithDrawer() {
|
||||
const { styles, theme } = useStyles();
|
||||
|
||||
const isInbox = useSessionStore(sessionSelectors.isInboxSession);
|
||||
const toggleDrawer = useGlobalStore((s) => s.toggleDrawer);
|
||||
const { t } = useTranslation(['chat']);
|
||||
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
|
||||
const avatar = useSessionStore(sessionMetaSelectors.currentAgentAvatar);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const displayTitle = isInbox ? t('inbox.title', { ns: 'chat' }) : title;
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<PageContainer
|
||||
extra={<ActionIcon icon={MoreHorizontal} onPress={() => router.push('/chat/setting')} />}
|
||||
left={<ActionIcon icon={AlignJustify} onPress={toggleDrawer} />}
|
||||
title={
|
||||
<Space>
|
||||
<Avatar avatar={avatar} size={AVATAR_SIZE} />
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={styles.title}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
enabled
|
||||
keyboardVerticalOffset={theme.marginXS}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ChatList />
|
||||
<ChatInput />
|
||||
</KeyboardAvoidingView>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{/* Hydration组件:处理URL和Store的双向同步 */}
|
||||
<Hydration />
|
||||
<SideBar>
|
||||
<TopicDrawer>{renderContent()}</TopicDrawer>
|
||||
</SideBar>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Avatar, PageContainer } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View } from 'react-native';
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
||||
|
||||
import { AVATAR_SIZE_LARGE } from '@/_const/common';
|
||||
import { AgentRoleEditSection } from '@/features/AgentRoleEdit/AgentRoleEditSection';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionMetaSelectors } from '@/store/session/selectors';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export default function AgentDetail() {
|
||||
const { t } = useTranslation(['chat']);
|
||||
const avatar = useSessionStore(sessionMetaSelectors.currentAgentAvatar);
|
||||
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
|
||||
const description = useSessionStore(sessionMetaSelectors.currentAgentDescription);
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title={t('setting.title', { ns: 'chat' })}>
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={40}
|
||||
extraKeyboardSpace={60}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar alt={title} avatar={avatar || '🤖'} size={AVATAR_SIZE_LARGE} />
|
||||
</View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||
<AgentRoleEditSection />
|
||||
</KeyboardAwareScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginSM,
|
||||
marginTop: token.marginLG,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
description: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeightLG,
|
||||
marginBottom: token.marginSM,
|
||||
paddingHorizontal: token.paddingLG,
|
||||
textAlign: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: token.controlHeight,
|
||||
marginBottom: token.marginSM,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
width: '100%',
|
||||
},
|
||||
historyRow: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: token.borderRadius,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: token.marginXXS,
|
||||
paddingHorizontal: token.paddingXXS,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
historySection: {
|
||||
alignSelf: 'stretch',
|
||||
marginHorizontal: token.margin,
|
||||
marginTop: token.margin,
|
||||
},
|
||||
historyText: {
|
||||
color: token.colorPrimary,
|
||||
flex: 1,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
textAlign: 'left',
|
||||
},
|
||||
historyTime: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSizeSM,
|
||||
marginLeft: token.marginSM,
|
||||
minWidth: 44,
|
||||
textAlign: 'right',
|
||||
},
|
||||
historyTitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXS,
|
||||
marginLeft: token.marginXXS,
|
||||
},
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: token.borderRadius,
|
||||
borderWidth: 1,
|
||||
margin: token.marginXXS,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
paddingVertical: token.paddingXXS,
|
||||
},
|
||||
tagText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
tagsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: token.marginXS,
|
||||
justifyContent: 'center',
|
||||
marginBottom: token.margin,
|
||||
marginTop: token.marginXXS,
|
||||
},
|
||||
title: {
|
||||
color: token.colorTextHeading,
|
||||
fontSize: token.fontSizeHeading4,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXS,
|
||||
marginTop: token.marginXXS,
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
color: token.colorText,
|
||||
flexShrink: 1,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
maxWidth: '100%',
|
||||
textAlign: 'left',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { Text, View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface SettingGroupProps extends ViewProps {
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingGroup = ({ children, style, title, ...rest }: SettingGroupProps) => {
|
||||
const { styles } = useStyles();
|
||||
const items = React.Children.toArray(children);
|
||||
|
||||
const newItems = items.map((child, index) => {
|
||||
if (React.isValidElement(child)) {
|
||||
const isLast = index === items.length - 1;
|
||||
return React.cloneElement(child as ReactElement<any>, { isLast });
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return (
|
||||
<View {...rest}>
|
||||
{title && (typeof title === 'string' ? <Text style={styles.title}>{title}</Text> : title)}
|
||||
<View style={[styles.container, style]}>{newItems}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
marginBottom: token.marginLG,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
title: {
|
||||
color: token.colorTextLabel,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXS,
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Icon, Switch } from '@lobehub/ui-rn';
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface SettingItemProps {
|
||||
customContent?: ReactNode;
|
||||
description?: string;
|
||||
extra?: string;
|
||||
href?: Href;
|
||||
isLast?: boolean;
|
||||
isSelected?: boolean;
|
||||
loading?: boolean;
|
||||
onPress?: () => void;
|
||||
onSwitchChange?: (value: boolean) => void;
|
||||
showCheckmark?: boolean;
|
||||
showNewBadge?: boolean;
|
||||
showSwitch?: boolean;
|
||||
switchValue?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const SettingItem = ({
|
||||
title,
|
||||
extra,
|
||||
onPress,
|
||||
href,
|
||||
showSwitch,
|
||||
switchValue,
|
||||
onSwitchChange,
|
||||
description,
|
||||
isLast,
|
||||
showNewBadge,
|
||||
isSelected = false,
|
||||
showCheckmark = false,
|
||||
loading = false,
|
||||
customContent,
|
||||
}: SettingItemProps) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const renderRightContent = () => {
|
||||
if (showSwitch) {
|
||||
return <Switch onValueChange={onSwitchChange} value={switchValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{extra && <Text style={styles.settingItemExtra}>{extra}</Text>}
|
||||
{showNewBadge && <View style={styles.badge} />}
|
||||
{loading && (
|
||||
<View style={styles.checkmark}>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
)}
|
||||
{!loading && showCheckmark && isSelected && (
|
||||
<Text style={styles.checkmark}>
|
||||
<Icon icon={Check} size="small" />
|
||||
</Text>
|
||||
)}
|
||||
{(onPress || href) && !showCheckmark && !loading && (
|
||||
<Text style={styles.settingItemArrow}>›</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text style={styles.settingItemTitle}>{title}</Text>
|
||||
{description && <Text style={styles.settingItemDescription}>{description}</Text>}
|
||||
</View>
|
||||
<View style={styles.settingItemRight}>{renderRightContent()}</View>
|
||||
</View>
|
||||
{customContent && <View style={styles.customContent}>{customContent}</View>}
|
||||
{!isLast && <View style={styles.separator} />}
|
||||
</View>
|
||||
);
|
||||
|
||||
const touchableContent = (
|
||||
<TouchableOpacity activeOpacity={1} onPress={onPress}>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link asChild href={href}>
|
||||
{touchableContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={1} onPress={onPress}>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
badge: {
|
||||
backgroundColor: token.colorError,
|
||||
borderRadius: token.borderRadiusXS,
|
||||
height: 8,
|
||||
marginRight: token.marginXS,
|
||||
width: 8,
|
||||
},
|
||||
checkmark: {},
|
||||
customContent: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
paddingBottom: token.paddingMD,
|
||||
paddingHorizontal: token.paddingMD,
|
||||
},
|
||||
separator: {
|
||||
backgroundColor: token.colorBorderSecondary,
|
||||
height: 1,
|
||||
marginHorizontal: token.marginMD,
|
||||
},
|
||||
settingItem: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 56,
|
||||
paddingHorizontal: token.paddingMD,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
settingItemArrow: {
|
||||
color: token.colorBorder,
|
||||
fontSize: token.sizeLG,
|
||||
marginLeft: token.marginSM,
|
||||
},
|
||||
settingItemDescription: {
|
||||
color: token.colorTextDescription,
|
||||
fontSize: token.fontSizeSM,
|
||||
marginTop: token.marginXS,
|
||||
},
|
||||
settingItemExtra: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeLG,
|
||||
marginRight: token.marginXXS,
|
||||
},
|
||||
settingItemLeft: {
|
||||
flexDirection: 'column',
|
||||
flexShrink: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingItemRight: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
settingItemTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SettingGroup } from './SettingGroup';
|
||||
export { SettingItem } from './SettingItem';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useThemedScreenOptions } from '@/_const/navigation';
|
||||
|
||||
export default function SettingRoutesLayout() {
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
return (
|
||||
<Stack screenOptions={themedScreenOptions}>
|
||||
<Stack.Screen name="providers/index" />
|
||||
<Stack.Screen name="locale/index" />
|
||||
<Stack.Screen name="account/index" />
|
||||
<Stack.Screen name="developer/index" />
|
||||
<Stack.Screen name="color/index" />
|
||||
<Stack.Screen name="themeMode/index" />
|
||||
<Stack.Screen name="fontSize/index" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Avatar, Button, PageContainer } from '@lobehub/ui-rn';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, View } from 'react-native';
|
||||
|
||||
import { safeReplaceLogin } from '@/navigation/safeLogin';
|
||||
import { useAuth, useAuthActions } from '@/store/user';
|
||||
|
||||
import { SettingGroup, SettingItem } from '../(components)';
|
||||
import { useStyles } from './style';
|
||||
|
||||
export default function AccountScreen() {
|
||||
const { t } = useTranslation(['setting', 'auth', 'error']);
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { logout } = useAuthActions();
|
||||
const { styles } = useStyles();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
t('actions.confirm', { ns: 'common' }),
|
||||
t('account.signOut.confirm', { ns: 'setting' }),
|
||||
[
|
||||
{
|
||||
style: 'cancel',
|
||||
text: t('actions.cancel', { ns: 'common' }),
|
||||
},
|
||||
{
|
||||
onPress: async () => {
|
||||
try {
|
||||
await logout();
|
||||
// Ensure we leave the authenticated stack immediately after logout
|
||||
safeReplaceLogin(router);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Logout failed';
|
||||
Alert.alert(t('error.title', { ns: 'error' }), errorMessage);
|
||||
}
|
||||
},
|
||||
style: 'destructive',
|
||||
text: t('actions.confirm', { ns: 'common' }),
|
||||
},
|
||||
],
|
||||
{ cancelable: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.safeAreaView}
|
||||
title={t('account.title', { ns: 'setting' })}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
{/* Avatar Section */}
|
||||
<View style={styles.avatarSection}>
|
||||
<Avatar alt={user.name || user.email} avatar={user.avatar} size={80} />
|
||||
</View>
|
||||
|
||||
{/* User Information */}
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
extra={user.name || user.username || ''}
|
||||
title={t('account.profile.name', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
extra={user.email}
|
||||
isLast
|
||||
title={t('account.profile.email', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
{/* Logout Section */}
|
||||
<View style={styles.signOutSection}>
|
||||
<Button block danger onPress={handleSignOut} size="large" type="primary">
|
||||
{t('account.signOut.label', { ns: 'setting' })}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginLG,
|
||||
paddingVertical: token.paddingXL,
|
||||
},
|
||||
container: {
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
signOutSection: {
|
||||
marginTop: token.marginXS,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const Preview = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['setting']);
|
||||
|
||||
// 移动端导航栏
|
||||
const navbar = (
|
||||
<View style={styles.navbar}>
|
||||
<View style={styles.navLeft}>
|
||||
<View style={styles.backButton} />
|
||||
<View style={styles.navTitle} />
|
||||
</View>
|
||||
<View style={styles.navRight}>
|
||||
<View style={styles.navIcon} />
|
||||
<View style={styles.navIcon} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 聊天消息内容
|
||||
const chatContent = (
|
||||
<View style={styles.chatContent}>
|
||||
{/* 用户消息 1 */}
|
||||
<View style={[styles.messageContainer, styles.messageContainerUser]}>
|
||||
<View style={styles.messageBubbleUser}>
|
||||
<Text style={[styles.messageText, styles.messageTextUser]}>
|
||||
{t('color.previewMessages.userHowToUse', { ns: 'setting' })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* AI 回复消息 1 */}
|
||||
<View style={[styles.messageContainer, styles.messageContainerBot]}>
|
||||
<View style={styles.messageBubbleBot}>
|
||||
<Text style={[styles.messageText, styles.messageTextBot]}>
|
||||
{t('color.previewMessages.botHowToUse', { ns: 'setting' })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.messageContainer, styles.messageContainerUser]}>
|
||||
<View style={styles.messageBubbleUser}>
|
||||
<Text style={[styles.messageText, styles.messageTextUser]}>很棒!</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* AI 回复消息 3 */}
|
||||
<View style={[styles.messageContainer, styles.messageContainerBot]}>
|
||||
<View style={styles.messageBubbleBot}>
|
||||
<Text style={[styles.messageText, styles.messageTextBot]}>
|
||||
很高兴你喜欢!这个预览功能让你可以在应用设置之前直观地看到主题效果。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 输入区域
|
||||
const inputArea = (
|
||||
<View style={styles.inputArea}>
|
||||
<View style={styles.inputBox} />
|
||||
<View style={styles.sendButton} />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container]}>
|
||||
{navbar}
|
||||
{chatContent}
|
||||
{inputArea}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default Preview;
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorTextSecondary,
|
||||
borderColor: token.colorPrimary,
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
},
|
||||
|
||||
// 聊天内容区域
|
||||
chatArea: {},
|
||||
|
||||
chatContent: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
gap: token.marginSM,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
|
||||
// 输入区域
|
||||
inputArea: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
height: 60,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
|
||||
inputBox: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: 18,
|
||||
borderWidth: 0.5,
|
||||
flex: 1,
|
||||
height: 36,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
|
||||
inputPlaceholder: {
|
||||
backgroundColor: token.colorTextTertiary,
|
||||
borderRadius: 1,
|
||||
height: 2,
|
||||
width: '60%',
|
||||
},
|
||||
|
||||
messageBubbleBot: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
borderRadius: token.borderRadius,
|
||||
borderWidth: 0.5,
|
||||
marginBottom: 2,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
|
||||
messageBubbleUser: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
marginBottom: 2,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
|
||||
// 消息样式
|
||||
messageContainer: {
|
||||
maxWidth: '75%',
|
||||
},
|
||||
|
||||
messageContainerBot: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
|
||||
messageContainerUser: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
|
||||
messageLine: {
|
||||
borderRadius: 1,
|
||||
height: 2,
|
||||
marginVertical: 1,
|
||||
},
|
||||
|
||||
messageLineBot: {
|
||||
backgroundColor: token.colorTextQuaternary,
|
||||
},
|
||||
|
||||
messageLineFull: {
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
messageLinePartial: {
|
||||
width: '75%',
|
||||
},
|
||||
|
||||
messageLineShort: {
|
||||
width: '45%',
|
||||
},
|
||||
|
||||
messageLineUser: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
|
||||
messageText: {
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeight,
|
||||
},
|
||||
|
||||
messageTextBot: {
|
||||
color: token.colorText,
|
||||
},
|
||||
|
||||
messageTextUser: {
|
||||
color: token.colorText,
|
||||
},
|
||||
|
||||
navIcon: {
|
||||
backgroundColor: token.colorTextSecondary,
|
||||
borderRadius: token.borderRadiusXS,
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
|
||||
navLeft: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
},
|
||||
|
||||
navRight: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
},
|
||||
|
||||
navTitle: {
|
||||
backgroundColor: token.colorTextSecondary,
|
||||
borderRadius: token.borderRadiusXS,
|
||||
height: 10,
|
||||
width: 80,
|
||||
},
|
||||
// 移动端顶部导航栏
|
||||
navbar: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
flexDirection: 'row',
|
||||
height: 44,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
sendButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorPrimary,
|
||||
borderRadius: 18,
|
||||
height: 36,
|
||||
justifyContent: 'center',
|
||||
width: 36,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ColorSwatches, PageContainer } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useSettingStore } from '@/store/setting';
|
||||
import {
|
||||
NeutralColors,
|
||||
PrimaryColors,
|
||||
findCustomThemeName,
|
||||
neutralColors,
|
||||
primaryColors,
|
||||
} from '@/theme';
|
||||
|
||||
import { SettingGroup, SettingItem } from '../(components)';
|
||||
import { useStyles } from '../styles';
|
||||
import Preview from './(components)/Preview';
|
||||
|
||||
export default function ThemeSettingScreen() {
|
||||
const { t } = useTranslation(['setting']);
|
||||
const { styles } = useStyles();
|
||||
|
||||
const { primaryColor, neutralColor, setPrimaryColor, setNeutralColor } = useSettingStore();
|
||||
|
||||
const primaryColorSwatchesData = [
|
||||
{ color: 'rgba(0, 0, 0, 0)', title: 'Default' },
|
||||
{ color: primaryColors.red, title: 'Red' },
|
||||
{ color: primaryColors.orange, title: 'Orange' },
|
||||
{ color: primaryColors.gold, title: 'Gold' },
|
||||
{ color: primaryColors.yellow, title: 'Yellow' },
|
||||
{ color: primaryColors.lime, title: 'Lime' },
|
||||
{ color: primaryColors.green, title: 'Green' },
|
||||
{ color: primaryColors.cyan, title: 'Cyan' },
|
||||
{ color: primaryColors.blue, title: 'Blue' },
|
||||
{ color: primaryColors.geekblue, title: 'Geekblue' },
|
||||
{ color: primaryColors.purple, title: 'Purple' },
|
||||
{ color: primaryColors.magenta, title: 'Magenta' },
|
||||
{ color: primaryColors.volcano, title: 'Volcano' },
|
||||
];
|
||||
|
||||
const neutralColorSwatchesData = [
|
||||
{ color: 'rgba(0, 0, 0, 0)', title: 'Default' },
|
||||
{ color: neutralColors.mauve, title: 'Mauve' },
|
||||
{ color: neutralColors.slate, title: 'Slate' },
|
||||
{ color: neutralColors.sage, title: 'Sage' },
|
||||
{ color: neutralColors.olive, title: 'Olive' },
|
||||
{ color: neutralColors.sand, title: 'Sand' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title={t('color.title', { ns: 'setting' })}>
|
||||
<View style={styles.container}>
|
||||
<Preview />
|
||||
<SettingGroup style={{ marginTop: 16 }}>
|
||||
<SettingItem
|
||||
customContent={
|
||||
<ColorSwatches
|
||||
colors={primaryColorSwatchesData}
|
||||
gap={8}
|
||||
onChange={(color: any) => {
|
||||
const name = findCustomThemeName('primary', color) as PrimaryColors;
|
||||
setPrimaryColor(name || '');
|
||||
}}
|
||||
size={32}
|
||||
value={primaryColor ? primaryColors[primaryColor] : undefined}
|
||||
/>
|
||||
}
|
||||
title={t('color.primary.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
customContent={
|
||||
<ColorSwatches
|
||||
colors={neutralColorSwatchesData}
|
||||
gap={8}
|
||||
onChange={(color: any) => {
|
||||
const name = findCustomThemeName('neutral', color) as NeutralColors;
|
||||
setNeutralColor(name || '');
|
||||
}}
|
||||
size={32}
|
||||
value={neutralColor ? neutralColors[neutralColor] : undefined}
|
||||
/>
|
||||
}
|
||||
title={t('color.neutral.title', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import CustomServer from '@/features/setting/developer/CustomServer';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const CustomServerScreen = () => {
|
||||
const { t } = useTranslation(['setting']);
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack title={t('developer.customServer.title', { ns: 'setting' })}>
|
||||
<View style={styles.container}>
|
||||
<CustomServer />
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomServerScreen;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,134 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, View } from 'react-native';
|
||||
|
||||
import { useSettingStore } from '@/store/setting';
|
||||
|
||||
import { SettingGroup, SettingItem } from '../(components)';
|
||||
import { useStyles } from './styles';
|
||||
import {
|
||||
clearAuthData,
|
||||
expireAccessTokenNow,
|
||||
expireRefreshTokenNow,
|
||||
invalidateAccessToken,
|
||||
invalidateRefreshToken,
|
||||
} from './utils';
|
||||
|
||||
export default function DeveloperScreen() {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['setting']);
|
||||
const { developerMode, setDeveloperMode } = useSettingStore();
|
||||
|
||||
const confirmThenExecute = (
|
||||
confirmMessage: string,
|
||||
execute: () => Promise<void>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
const confirmTitle = t('actions.confirm', { ns: 'common' });
|
||||
const confirmText = t('actions.confirm', { ns: 'common' });
|
||||
const cancelText = t('actions.cancel', { ns: 'common' });
|
||||
const developerTitle = t('developer.title', { ns: 'setting' });
|
||||
const failurePrefix = t('developer.failurePrefix', { ns: 'setting' });
|
||||
|
||||
Alert.alert(
|
||||
confirmTitle,
|
||||
confirmMessage,
|
||||
[
|
||||
{ style: 'cancel', text: cancelText },
|
||||
{
|
||||
onPress: async () => {
|
||||
try {
|
||||
await execute();
|
||||
Alert.alert(developerTitle, successMessage);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
Alert.alert(developerTitle, `${failurePrefix}${msg}`);
|
||||
}
|
||||
},
|
||||
style: 'destructive',
|
||||
text: confirmText,
|
||||
},
|
||||
],
|
||||
{ cancelable: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer showBack title={t('developer.title', { ns: 'setting' })}>
|
||||
<View style={styles.container}>
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
onSwitchChange={(value) => setDeveloperMode(value)}
|
||||
showSwitch
|
||||
switchValue={developerMode}
|
||||
title={t('developer.mode.title', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
{developerMode && (
|
||||
<>
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
href="/setting/developer/custom-server"
|
||||
title={t('developer.customServer.title', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
onPress={() =>
|
||||
confirmThenExecute(
|
||||
t('developer.accessToken.expire.title', { ns: 'setting' }),
|
||||
expireAccessTokenNow,
|
||||
t('developer.accessToken.expire.success', { ns: 'setting' }),
|
||||
)
|
||||
}
|
||||
title={t('developer.accessToken.expire.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={() =>
|
||||
confirmThenExecute(
|
||||
t('developer.refreshToken.expire.title', { ns: 'setting' }),
|
||||
expireRefreshTokenNow,
|
||||
t('developer.refreshToken.expire.success', { ns: 'setting' }),
|
||||
)
|
||||
}
|
||||
title={t('developer.refreshToken.expire.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={() =>
|
||||
confirmThenExecute(
|
||||
t('developer.accessToken.invalidate.title', { ns: 'setting' }),
|
||||
invalidateAccessToken,
|
||||
t('developer.accessToken.invalidate.success', { ns: 'setting' }),
|
||||
)
|
||||
}
|
||||
title={t('developer.accessToken.invalidate.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={() =>
|
||||
confirmThenExecute(
|
||||
t('developer.refreshToken.invalidate.title', { ns: 'setting' }),
|
||||
invalidateRefreshToken,
|
||||
t('developer.refreshToken.invalidate.success', { ns: 'setting' }),
|
||||
)
|
||||
}
|
||||
title={t('developer.refreshToken.invalidate.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={() =>
|
||||
confirmThenExecute(
|
||||
t('developer.clearAuthData.title', { ns: 'setting' }),
|
||||
clearAuthData,
|
||||
t('developer.clearAuthData.success', { ns: 'setting' }),
|
||||
)
|
||||
}
|
||||
title={t('developer.clearAuthData.title', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Token } from '@/_types/user';
|
||||
import { TokenStorage } from '@/services/_auth/tokenStorage';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
const updateToken = async (mutate: (token: Token) => Token): Promise<void> => {
|
||||
const current = await TokenStorage.getToken();
|
||||
if (!current) throw new Error('当前无可用 Token');
|
||||
const next = mutate(current);
|
||||
await TokenStorage.storeToken(next);
|
||||
};
|
||||
|
||||
export const expireAccessTokenNow = async (): Promise<void> => {
|
||||
const now = Math.floor(Date.now() / 1000) - 10;
|
||||
await updateToken((t) => ({ ...t, expiresAt: now }));
|
||||
};
|
||||
|
||||
export const expireRefreshTokenNow = async (): Promise<void> => {
|
||||
const now = Math.floor(Date.now() / 1000) - 10;
|
||||
await updateToken((t) => ({ ...t, refreshExpiresAt: now }));
|
||||
};
|
||||
|
||||
export const invalidateAccessToken = async (): Promise<void> => {
|
||||
await updateToken((t) => ({ ...t, accessToken: 'invalid-access-token' }));
|
||||
};
|
||||
|
||||
export const invalidateRefreshToken = async (): Promise<void> => {
|
||||
await updateToken((t) => ({ ...t, refreshToken: 'invalid-refresh-token' }));
|
||||
};
|
||||
|
||||
export const clearAuthData = async (): Promise<void> => {
|
||||
await TokenStorage.clearAll();
|
||||
useUserStore.getState().clearAuthState();
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Markdown } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useSettingStore } from '@/store/setting';
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
const usePreviewStyles = createStyles(({ token }) => ({
|
||||
aiBubble: {
|
||||
width: '100%',
|
||||
},
|
||||
bubble: {
|
||||
borderRadius: token.borderRadius,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
row: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
marginVertical: token.marginXS,
|
||||
},
|
||||
rowRight: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
},
|
||||
}));
|
||||
|
||||
const Preview = () => {
|
||||
const { styles } = usePreviewStyles();
|
||||
const { fontSize } = useSettingStore();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.row, styles.rowRight]}>
|
||||
<View style={[styles.bubble, styles.userBubble]}>
|
||||
<Markdown fontSize={fontSize}>{'我想把对话字体调大一些,该怎么做?'}</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.aiBubble]}>
|
||||
<View style={[styles.bubble]}>
|
||||
<Markdown
|
||||
fontSize={fontSize}
|
||||
>{`**如何调整字体大小?**\n\n使用下方的滑块即可调节字体大小:向左变小,向右变大。拖动时,这里会实时预览效果。\n\n小提示:选择“${'标准'}”刻度可快速恢复默认大小。`}</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowRight]}>
|
||||
<View style={[styles.bubble, styles.userBubble]}>
|
||||
<Markdown fontSize={fontSize}>{'很棒!'}</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.aiBubble]}>
|
||||
<View style={[styles.bubble]}>
|
||||
<Markdown fontSize={fontSize}>
|
||||
{'很高兴你喜欢!这个预览功能让你可以在应用设置之前直观地看到在对话框中的对话效果。'}
|
||||
</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preview;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PageContainer, Slider } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { FONT_SIZE_LARGE, FONT_SIZE_SMALL, FONT_SIZE_STANDARD } from '@/_const/common';
|
||||
import { useSettingStore } from '@/store/setting';
|
||||
|
||||
import Preview from './components/Preview';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export default function FontSizeSettingScreen() {
|
||||
const { t } = useTranslation(['setting']);
|
||||
const { styles } = useStyles();
|
||||
|
||||
const { fontSize, setFontSize } = useSettingStore();
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.safeAreaView}
|
||||
title={t('fontSize.title', { ns: 'setting' })}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 120 }}
|
||||
style={styles.container}
|
||||
>
|
||||
<Preview />
|
||||
</ScrollView>
|
||||
<View style={styles.bottomBarWrapper}>
|
||||
<Slider
|
||||
marks={{
|
||||
[FONT_SIZE_LARGE]: { label: <Text style={styles.fontSizeLarge}>A</Text> },
|
||||
[FONT_SIZE_SMALL]: { label: <Text style={styles.fontSizeSmall}>A</Text> },
|
||||
[FONT_SIZE_STANDARD]: {
|
||||
label: (
|
||||
<Text style={styles.fontSizeStandard}>
|
||||
{t('fontSize.standard', { ns: 'setting' })}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
}}
|
||||
max={FONT_SIZE_LARGE}
|
||||
min={FONT_SIZE_SMALL}
|
||||
onChangeComplete={setFontSize}
|
||||
step={1}
|
||||
value={fontSize}
|
||||
/>
|
||||
<View style={styles.fontSizeContainer}>
|
||||
<Text style={styles.fontSizeText}>{t('fontSize.text', { ns: 'setting' })}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { FONT_SIZE_LARGE, FONT_SIZE_SMALL, FONT_SIZE_STANDARD } from '@/_const/common';
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
bottomBarWrapper: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
bottom: 0,
|
||||
height: 160,
|
||||
left: 0,
|
||||
paddingHorizontal: token.paddingLG,
|
||||
paddingVertical: token.paddingXL,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
padding: token.paddingContentHorizontal,
|
||||
width: '100%',
|
||||
},
|
||||
fontSizeContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
marginTop: token.marginLG,
|
||||
},
|
||||
fontSizeLarge: {
|
||||
color: token.colorText,
|
||||
fontSize: FONT_SIZE_LARGE,
|
||||
},
|
||||
fontSizeSmall: {
|
||||
color: token.colorText,
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
},
|
||||
fontSizeStandard: {
|
||||
color: token.colorText,
|
||||
fontSize: FONT_SIZE_STANDARD,
|
||||
},
|
||||
fontSizeText: {
|
||||
color: token.colorTextDescription,
|
||||
fontSize: token.fontSize,
|
||||
},
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,139 @@
|
||||
import { PageContainer, Toast } from '@lobehub/ui-rn';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView } from 'react-native';
|
||||
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { useSettingStore } from '@/store/setting';
|
||||
import { useThemeMode } from '@/theme';
|
||||
|
||||
import { version } from '../../../package.json';
|
||||
import { SettingGroup, SettingItem } from './(components)';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export default function SettingScreen() {
|
||||
const { t } = useTranslation(['setting', 'auth', 'common', 'error']);
|
||||
const { getLocaleDisplayName } = useLocale();
|
||||
const { themeMode } = useThemeMode();
|
||||
const { developerMode, setDeveloperMode } = useSettingStore();
|
||||
|
||||
const [tapCount, setTapCount] = useState(0);
|
||||
const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getThemeModeDisplayName = () => {
|
||||
if (themeMode === 'auto') {
|
||||
return t('themeMode.auto', { ns: 'setting' });
|
||||
}
|
||||
return t(`themeMode.${themeMode}`, { ns: 'setting' });
|
||||
};
|
||||
|
||||
const handleVersionTap = () => {
|
||||
const newTapCount = tapCount + 1;
|
||||
setTapCount(newTapCount);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (tapTimeoutRef.current) {
|
||||
clearTimeout(tapTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 开发者模式开启后提示
|
||||
if (developerMode) {
|
||||
if (newTapCount >= 3) {
|
||||
Toast.info(t('developer.mode.already', { ns: 'setting' }));
|
||||
setTapCount(0);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 连续点击7次开启开发者模式
|
||||
if (newTapCount >= 7) {
|
||||
setDeveloperMode(true);
|
||||
setTapCount(0);
|
||||
Toast.success(t('developer.mode.enabled', { ns: 'setting' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newTapCount >= 3) {
|
||||
const remaining = 7 - newTapCount;
|
||||
Toast.info(
|
||||
t('developer.mode.remaining', {
|
||||
count: remaining,
|
||||
ns: 'setting',
|
||||
}),
|
||||
1500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2秒后重置计数器
|
||||
tapTimeoutRef.current = setTimeout(() => {
|
||||
setTapCount(0);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const { styles } = useStyles();
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (tapTimeoutRef.current) {
|
||||
clearTimeout(tapTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContainer showBack title={t('title', { ns: 'setting' })}>
|
||||
<ScrollView style={[styles.container]}>
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
extra={getThemeModeDisplayName()}
|
||||
href={'/setting/themeMode'}
|
||||
title={t('themeMode.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem href={'/setting/color'} title={t('color.title', { ns: 'setting' })} />
|
||||
<SettingItem href={'/setting/fontSize'} title={t('fontSize.title', { ns: 'setting' })} />
|
||||
<SettingItem
|
||||
extra={getLocaleDisplayName()}
|
||||
href="/setting/locale"
|
||||
title={t('locale.title', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup>
|
||||
<SettingItem href="/setting/account" title={t('account.title', { ns: 'setting' })} />
|
||||
<SettingItem href="/setting/providers" title={t('providers', { ns: 'setting' })} />
|
||||
</SettingGroup>
|
||||
|
||||
{developerMode && (
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
href="/setting/developer"
|
||||
title={t('developer.title', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
href="https://lobehub.com/docs?utm_source=mobile"
|
||||
title={t('help', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
href="https://github.com/lobehub/lobe-chat/issues/new/choose"
|
||||
title={t('feedback', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
href="https://lobehub.com/changelog"
|
||||
title={t('changelog', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem href="mailto:support@lobehub.com" title={t('support', { ns: 'setting' })} />
|
||||
<SettingItem
|
||||
extra={version}
|
||||
onPress={handleVersionTap}
|
||||
title={t('version', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView } from 'react-native';
|
||||
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { LANGUAGE_OPTIONS, LocaleMode } from '@/i18n/resource';
|
||||
|
||||
import { SettingGroup, SettingItem } from '../(components)';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export default function LocaleScreen() {
|
||||
const { styles } = useStyles();
|
||||
const { localeMode, changeLocale } = useLocale();
|
||||
const { t } = useTranslation(['setting']);
|
||||
const [pendingLocale, setPendingLocale] = React.useState<LocaleMode | null>(null);
|
||||
|
||||
const handleLocaleChange = async (locale: LocaleMode) => {
|
||||
if (pendingLocale || localeMode === locale) return;
|
||||
try {
|
||||
setPendingLocale(locale);
|
||||
await changeLocale(locale);
|
||||
} finally {
|
||||
setPendingLocale(null);
|
||||
}
|
||||
};
|
||||
|
||||
const localeOptions = [
|
||||
{ label: t('locale.auto.title', { ns: 'setting' }), value: 'auto' },
|
||||
...LANGUAGE_OPTIONS,
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.safeAreaView}
|
||||
title={t('locale.title', { ns: 'setting' })}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.contentContainer}>
|
||||
<SettingGroup>
|
||||
{localeOptions.map((option, index) => (
|
||||
<SettingItem
|
||||
description={
|
||||
option.value === 'auto' ? t('locale.auto.description', { ns: 'setting' }) : ''
|
||||
}
|
||||
isLast={index === localeOptions.length - 1}
|
||||
isSelected={localeMode === option.value}
|
||||
key={option.value}
|
||||
loading={pendingLocale === (option.value as LocaleMode)}
|
||||
onPress={() => handleLocaleChange(option.value as LocaleMode)}
|
||||
showCheckmark={true}
|
||||
title={option.label}
|
||||
/>
|
||||
))}
|
||||
</SettingGroup>
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
contentContainer: {
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,521 @@
|
||||
import { AiProviderDetailItem } from '@lobechat/types';
|
||||
import { ModelIcon } from '@lobehub/icons-rn';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InstantSwitch,
|
||||
ModelInfoTags,
|
||||
PageContainer,
|
||||
Tag,
|
||||
useToast,
|
||||
} from '@lobehub/ui-rn';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams, useNavigation } from 'expo-router';
|
||||
import { RefreshCcw } from 'lucide-react-native';
|
||||
import { AiProviderModelListItem } from 'model-bank';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
|
||||
import ConfigurationSection from '@/features/setting/providers/ConfigurationSection';
|
||||
import ProviderInfoSection from '@/features/setting/providers/ProviderInfoSection';
|
||||
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
|
||||
import { aiModelSelectors } from '@/store/aiInfra/selectors';
|
||||
import { useTheme } from '@/theme';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
// 定义FlashList数据项类型
|
||||
type FlashListItem =
|
||||
| { data: AiProviderDetailItem; id: string; type: 'provider-info' }
|
||||
| { data: AiProviderDetailItem; id: string; type: 'configuration' }
|
||||
| {
|
||||
data: { isFetching: boolean; searchKeyword: string; totalCount: number };
|
||||
id: string;
|
||||
type: 'models-header';
|
||||
}
|
||||
| { data: { count: number; title: string }; id: string; type: 'section-header' }
|
||||
| { data: AiProviderModelListItem; id: string; type: 'model' }
|
||||
| { data: { message: string }; id: string; type: 'empty' };
|
||||
|
||||
// ModelCard组件(从ModelsSection提取)
|
||||
const ModelCard = React.memo<{
|
||||
model: AiProviderModelListItem;
|
||||
onToggle: (_modelId: string, _enabled: boolean) => void;
|
||||
}>(({ model, onToggle }) => {
|
||||
const { styles } = useStyles();
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation(['setting']);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
onToggle(model.id, value);
|
||||
};
|
||||
|
||||
const handleCopyModelId = () => {
|
||||
Clipboard.setString(model.id);
|
||||
toast.success(t('aiProviders.models.copySuccess', { ns: 'setting' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.modelCard}>
|
||||
<View style={styles.modelCardContent}>
|
||||
<ModelIcon model={model.id} size={32} />
|
||||
<View style={styles.modelInfo}>
|
||||
<View style={styles.modelTopRow}>
|
||||
<Text style={styles.modelName}>{model.displayName || model.id}</Text>
|
||||
<ModelInfoTags {...model.abilities} contextWindowTokens={model.contextWindowTokens} />
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleCopyModelId} style={styles.modelBottomRow}>
|
||||
<Tag style={styles.modelIdTag} textStyle={styles.modelIdText}>
|
||||
{model.id}
|
||||
</Tag>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.modelSwitchContainer}>
|
||||
<InstantSwitch
|
||||
enabled={model.enabled}
|
||||
onChange={async (enabled) => {
|
||||
handleToggle(enabled);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const ProviderDetailPage = () => {
|
||||
const navigation = useNavigation();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['setting']);
|
||||
const token = useTheme();
|
||||
const toast = useToast();
|
||||
|
||||
// Models相关状态(从ModelsSection移过来)
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [displayedCount, setDisplayedCount] = useState(20);
|
||||
|
||||
// Store hooks for models
|
||||
const { useFetchAiProviderModels, fetchRemoteModelList, toggleModelEnabled } = useAiInfraStore();
|
||||
const { data: modelList, isLoading: isModelsLoading } = useFetchAiProviderModels(id!);
|
||||
const totalModels = useAiInfraStore(aiModelSelectors.totalAiProviderModelList);
|
||||
|
||||
// 先从本地配置获取基础信息
|
||||
const builtinProviderCard = useMemo(
|
||||
() => DEFAULT_MODEL_PROVIDER_LIST.find((v) => v.id === id),
|
||||
[id],
|
||||
);
|
||||
|
||||
// 获取用户的provider配置
|
||||
const { useFetchAiProviderItem } = useAiInfraStore();
|
||||
const { data: userConfig, isLoading, error } = useFetchAiProviderItem(id!);
|
||||
|
||||
// 获取provider配置状态
|
||||
const isConfigLoading = useAiInfraStore(aiProviderSelectors.isAiProviderConfigLoading(id!));
|
||||
|
||||
// 合并配置
|
||||
const providerDetail = useMemo<AiProviderDetailItem | undefined>(() => {
|
||||
if (!builtinProviderCard && !userConfig) return undefined;
|
||||
|
||||
// 如果是内置provider,合并本地配置和用户配置
|
||||
if (builtinProviderCard) {
|
||||
return {
|
||||
...builtinProviderCard,
|
||||
...userConfig,
|
||||
|
||||
// 保持checkModel的默认值,只有用户明确配置了才使用用户的值
|
||||
checkModel: userConfig?.checkModel ?? builtinProviderCard.checkModel,
|
||||
|
||||
// 保留用户配置的这些字段
|
||||
enabled: userConfig?.enabled ?? builtinProviderCard.enabled,
|
||||
|
||||
keyVaults: userConfig?.keyVaults || {},
|
||||
// 确保settings字段来自本地配置
|
||||
settings: builtinProviderCard.settings,
|
||||
} as AiProviderDetailItem;
|
||||
}
|
||||
|
||||
// 如果是自定义provider,直接使用用户配置
|
||||
return userConfig;
|
||||
}, [builtinProviderCard, userConfig]);
|
||||
|
||||
// 模型相关处理函数
|
||||
const handleFetchModels = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
await fetchRemoteModelList(id!);
|
||||
toast.success(t('aiProviders.models.fetchSuccess', { ns: 'setting' }));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
t('aiProviders.models.fetchFailed', { ns: 'setting' }),
|
||||
);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [id, fetchRemoteModelList, toast, t]);
|
||||
|
||||
const handleToggleModel = useCallback(
|
||||
async (modelId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleModelEnabled({ enabled, id: modelId });
|
||||
console.log(`Model ${modelId} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle model ${modelId}:`, error);
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
enabled
|
||||
? t('aiProviders.models.enableFailed', { ns: 'setting' })
|
||||
: t('aiProviders.models.disableFailed', { ns: 'setting' }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[toggleModelEnabled, t],
|
||||
);
|
||||
|
||||
// 动态设置页面标题
|
||||
useEffect(() => {
|
||||
const displayName = providerDetail?.name || builtinProviderCard?.name || id;
|
||||
if (displayName) {
|
||||
navigation.setOptions({
|
||||
headerTitle: displayName,
|
||||
title: displayName,
|
||||
});
|
||||
}
|
||||
}, [navigation, providerDetail?.name, builtinProviderCard?.name, id]);
|
||||
|
||||
// 重置显示数量当搜索关键词变化时
|
||||
useEffect(() => {
|
||||
setDisplayedCount(20);
|
||||
}, [searchKeyword]);
|
||||
|
||||
const headerTitle = providerDetail?.name || id || '';
|
||||
|
||||
// 处理模型数据(不包含UI逻辑)
|
||||
const { enabledModels, disabledModels, totalFilteredCount } = useMemo(() => {
|
||||
if (!modelList) return { disabledModels: [], enabledModels: [], totalFilteredCount: 0 };
|
||||
|
||||
const filtered = modelList.filter((model) => {
|
||||
if (!searchKeyword.trim()) return true;
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
return (
|
||||
model.id.toLowerCase().includes(keyword) ||
|
||||
model.displayName?.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
|
||||
const enabled = filtered.filter((model) => model.enabled);
|
||||
const disabled = filtered.filter((model) => !model.enabled);
|
||||
|
||||
return {
|
||||
disabledModels: disabled,
|
||||
enabledModels: enabled,
|
||||
totalFilteredCount: enabled.length + disabled.length,
|
||||
};
|
||||
}, [modelList, searchKeyword]);
|
||||
|
||||
// 构建FlashList统一数据结构
|
||||
const flashListData = useMemo<FlashListItem[]>(() => {
|
||||
if (!providerDetail) return [];
|
||||
|
||||
const items: FlashListItem[] = [];
|
||||
|
||||
// 1. Provider信息section和Configuration section
|
||||
items.push(
|
||||
{
|
||||
data: providerDetail,
|
||||
id: 'provider-info',
|
||||
type: 'provider-info',
|
||||
},
|
||||
{
|
||||
data: providerDetail,
|
||||
id: 'configuration',
|
||||
type: 'configuration',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
isFetching,
|
||||
searchKeyword,
|
||||
totalCount: totalModels,
|
||||
},
|
||||
id: 'models-header',
|
||||
type: 'models-header',
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Models数据处理
|
||||
if (isModelsLoading) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (totalFilteredCount === 0) {
|
||||
items.push({
|
||||
data: {
|
||||
message: searchKeyword.trim()
|
||||
? t('aiProviders.models.emptyWithSearch', { ns: 'setting' })
|
||||
: t('aiProviders.models.emptyNoSearch', { ns: 'setting' }),
|
||||
},
|
||||
id: 'empty-models',
|
||||
type: 'empty',
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// 5. 启用的模型
|
||||
if (enabledModels.length > 0) {
|
||||
items.push({
|
||||
data: {
|
||||
count: enabledModels.length,
|
||||
title: t('aiProviders.list.enabled', { ns: 'setting' }),
|
||||
},
|
||||
id: 'enabled-header',
|
||||
type: 'section-header',
|
||||
});
|
||||
|
||||
// 添加显示的启用模型
|
||||
const displayedEnabled = enabledModels.slice(
|
||||
0,
|
||||
Math.min(displayedCount, enabledModels.length),
|
||||
);
|
||||
displayedEnabled.forEach((model) => {
|
||||
items.push({
|
||||
data: model,
|
||||
id: `model-${model.id}`,
|
||||
type: 'model',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 禁用的模型
|
||||
const remainingCount = Math.max(0, displayedCount - enabledModels.length);
|
||||
if (disabledModels.length > 0 && remainingCount > 0) {
|
||||
items.push({
|
||||
data: {
|
||||
count: disabledModels.length,
|
||||
title: t('aiProviders.list.disabled', { ns: 'setting' }),
|
||||
},
|
||||
id: 'disabled-header',
|
||||
type: 'section-header',
|
||||
});
|
||||
|
||||
// 添加显示的禁用模型
|
||||
const displayedDisabled = disabledModels.slice(0, remainingCount);
|
||||
displayedDisabled.forEach((model) => {
|
||||
items.push({
|
||||
data: model,
|
||||
id: `model-${model.id}`,
|
||||
type: 'model',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
providerDetail,
|
||||
searchKeyword,
|
||||
totalModels,
|
||||
isFetching,
|
||||
isModelsLoading,
|
||||
enabledModels,
|
||||
disabledModels,
|
||||
totalFilteredCount,
|
||||
displayedCount,
|
||||
t,
|
||||
]);
|
||||
|
||||
// 统一的renderItem函数
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: FlashListItem }) => {
|
||||
switch (item.type) {
|
||||
case 'provider-info': {
|
||||
return <ProviderInfoSection provider={item.data} />;
|
||||
}
|
||||
|
||||
case 'configuration': {
|
||||
return <ConfigurationSection provider={item.data} />;
|
||||
}
|
||||
|
||||
case 'models-header': {
|
||||
return (
|
||||
<View style={styles.modelsHeader}>
|
||||
<View style={styles.modelsTitleRow}>
|
||||
<View style={styles.modelsTitleContainer}>
|
||||
<Text style={styles.modelsTitle}>
|
||||
{t('aiProviders.models.title', { ns: 'setting' })}
|
||||
</Text>
|
||||
<Text style={styles.modelsCount}>
|
||||
{t('aiProviders.models.modelsAvailable', {
|
||||
count: item.data.totalCount,
|
||||
ns: 'setting',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.modelsActions}>
|
||||
<Button
|
||||
disabled={item.data.isFetching}
|
||||
icon={<RefreshCcw />}
|
||||
loading={item.data.isFetching}
|
||||
onPress={handleFetchModels}
|
||||
type="primary"
|
||||
>
|
||||
{item.data.isFetching
|
||||
? t('aiProviders.models.fetching', { ns: 'setting' })
|
||||
: t('aiProviders.models.fetch', { ns: 'setting' })}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input.Search
|
||||
onChangeText={setSearchKeyword}
|
||||
placeholder={t('aiProviders.models.searchPlaceholder', { ns: 'setting' })}
|
||||
style={styles.modelsSearchInput}
|
||||
value={searchKeyword}
|
||||
variant="outlined"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'section-header': {
|
||||
return (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionHeaderText}>
|
||||
{item.data.title} ({item.data.count})
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'model': {
|
||||
return <ModelCard model={item.data} onToggle={handleToggleModel} />;
|
||||
}
|
||||
|
||||
case 'empty': {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>{item.data.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
styles,
|
||||
token,
|
||||
t,
|
||||
handleFetchModels,
|
||||
handleToggleModel,
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
setDisplayedCount,
|
||||
],
|
||||
);
|
||||
|
||||
// FlashList的keyExtractor
|
||||
const keyExtractor = useCallback((item: FlashListItem) => item.id, []);
|
||||
|
||||
// 自动加载更多
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (displayedCount < totalFilteredCount) {
|
||||
setDisplayedCount((prev) => Math.min(prev + 20, totalFilteredCount));
|
||||
}
|
||||
}, [displayedCount, totalFilteredCount]);
|
||||
|
||||
// ListFooterComponent - 优雅的加载提示
|
||||
const renderFooter = useCallback(() => {
|
||||
if (isModelsLoading) {
|
||||
return (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator color={token.colorPrimary} size="small" />
|
||||
<Text style={styles.footerText}>
|
||||
{t('aiProviders.models.loading', { ns: 'setting' })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayedCount < totalFilteredCount) {
|
||||
return (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator color={token.colorTextSecondary} size="small" />
|
||||
<Text style={styles.footerText}>
|
||||
{t('aiProviders.models.loadingMore', { ns: 'setting' })} ({displayedCount}/
|
||||
{totalFilteredCount})
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalFilteredCount > 0) {
|
||||
return (
|
||||
<View style={styles.footerComplete}>
|
||||
<Text style={styles.footerCompleteText}>
|
||||
{t('aiProviders.models.allLoaded', { ns: 'setting' })} ({totalFilteredCount})
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [isModelsLoading, displayedCount, totalFilteredCount, token, t]);
|
||||
|
||||
if (isLoading || isConfigLoading) {
|
||||
return (
|
||||
<PageContainer showBack style={styles.container} title={headerTitle}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>
|
||||
{t('aiProviders.detail.loading', { ns: 'setting' })}
|
||||
</Text>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || (!providerDetail && !isLoading && !isConfigLoading)) {
|
||||
return (
|
||||
<PageContainer showBack style={styles.container} title={headerTitle}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.errorText}>
|
||||
{t('aiProviders.detail.loadFailed', { ns: 'setting' })}
|
||||
</Text>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!providerDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.container} title={headerTitle}>
|
||||
<FlashList
|
||||
ListFooterComponent={renderFooter}
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
data={flashListData}
|
||||
drawDistance={500}
|
||||
getItemType={(item) => item.type}
|
||||
keyExtractor={keyExtractor}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.2}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={true}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderDetailPage;
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
// 空状态
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
padding: token.paddingXL,
|
||||
},
|
||||
|
||||
emptyText: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSize,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
fontSize: token.fontSize,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
footerComplete: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
footerCompleteText: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSize,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Footer样式 - 优雅的加载提示
|
||||
footerLoading: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: token.paddingLG,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
marginLeft: token.marginXS,
|
||||
},
|
||||
|
||||
// 加载更多按钮
|
||||
loadMoreButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: token.borderRadius,
|
||||
marginHorizontal: token.padding,
|
||||
marginVertical: token.marginSM,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
loadMoreText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: token.paddingXL,
|
||||
},
|
||||
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
modelBottomRow: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
|
||||
// ModelCard样式
|
||||
modelCard: {
|
||||
// backgroundColor: token.colorBgContainer,
|
||||
// borderBottomColor: token.colorBorderSecondary,
|
||||
// borderBottomWidth: 1,
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
modelCardContent: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
modelIdTag: {
|
||||
// backgroundColor: token.colorFillTertiary,
|
||||
marginBottom: 0,
|
||||
marginLeft: 0,
|
||||
},
|
||||
|
||||
modelIdText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
modelInfo: {
|
||||
flex: 1,
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
modelName: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
modelSwitchContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
modelTopRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
marginBottom: token.marginXS,
|
||||
},
|
||||
|
||||
modelsActions: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
},
|
||||
|
||||
modelsCount: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
},
|
||||
|
||||
// Models相关样式(从ModelsSection迁移)
|
||||
modelsHeader: {
|
||||
// backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
marginBottom: token.marginSM,
|
||||
padding: token.padding,
|
||||
},
|
||||
|
||||
modelsSearchInput: {
|
||||
// paddingVertical: 10,
|
||||
},
|
||||
|
||||
modelsTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXXS,
|
||||
},
|
||||
|
||||
modelsTitleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
modelsTitleRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: token.marginSM,
|
||||
},
|
||||
|
||||
scrollContainer: {
|
||||
paddingHorizontal: token.paddingContentHorizontal,
|
||||
paddingTop: token.paddingContentVertical,
|
||||
},
|
||||
|
||||
// Section header样式
|
||||
sectionHeader: {
|
||||
// backgroundColor: token.colorFillQuaternary,
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
|
||||
sectionHeaderText: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,171 @@
|
||||
import { AiProviderListItem } from '@lobechat/types';
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import ProviderCard from '@/features/setting/providers/ProviderCard';
|
||||
import ProviderListSkeleton from '@/features/setting/providers/ProviderListSkeleton';
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { aiProviderSelectors } from '@/store/aiInfra/selectors';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
// 定义FlashList数据项类型
|
||||
type ProviderFlashListItem =
|
||||
| { data: { count: number; title: string }; id: string; type: 'section-header' }
|
||||
| { data: AiProviderListItem; id: string; type: 'provider' }
|
||||
| { data: { message: string }; id: string; type: 'empty' };
|
||||
|
||||
const ProviderList = () => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['setting']);
|
||||
|
||||
// 获取store数据和方法
|
||||
const { useFetchAiProviderList } = useAiInfraStore();
|
||||
|
||||
// 使用SWR获取provider列表
|
||||
const { isLoading, error } = useFetchAiProviderList();
|
||||
|
||||
// 使用store selectors来响应状态变化
|
||||
const enabledProviders = useAiInfraStore(aiProviderSelectors.enabledAiProviderList, isEqual);
|
||||
const disabledProviders = useAiInfraStore(aiProviderSelectors.disabledAiProviderList, isEqual);
|
||||
|
||||
// 构建FlashList统一数据结构
|
||||
const flashListData = useMemo<ProviderFlashListItem[]>(() => {
|
||||
const items: ProviderFlashListItem[] = [];
|
||||
|
||||
// 启用的Provider section
|
||||
if (enabledProviders.length > 0) {
|
||||
items.push({
|
||||
data: {
|
||||
count: enabledProviders.length,
|
||||
title: t('aiProviders.list.enabled', { ns: 'setting' }),
|
||||
},
|
||||
id: 'enabled-header',
|
||||
type: 'section-header',
|
||||
});
|
||||
|
||||
enabledProviders.forEach((provider) => {
|
||||
items.push({
|
||||
data: provider,
|
||||
id: `provider-${provider.id}`,
|
||||
type: 'provider',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 禁用的Provider section
|
||||
if (disabledProviders.length > 0) {
|
||||
items.push({
|
||||
data: {
|
||||
count: disabledProviders.length,
|
||||
title: t('aiProviders.list.disabled', { ns: 'setting' }),
|
||||
},
|
||||
id: 'disabled-header',
|
||||
type: 'section-header',
|
||||
});
|
||||
|
||||
disabledProviders.forEach((provider) => {
|
||||
items.push({
|
||||
data: provider,
|
||||
id: `provider-${provider.id}`,
|
||||
type: 'provider',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有任何Provider
|
||||
if (items.length === 0) {
|
||||
items.push({
|
||||
data: {
|
||||
message: t('aiProviders.list.empty', { ns: 'setting' }),
|
||||
},
|
||||
id: 'empty-providers',
|
||||
type: 'empty',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [enabledProviders, disabledProviders, t]);
|
||||
|
||||
// 统一的renderItem函数
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: ProviderFlashListItem }) => {
|
||||
switch (item.type) {
|
||||
case 'section-header': {
|
||||
return (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{item.data.title} ({item.data.count})
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'provider': {
|
||||
return <ProviderCard provider={item.data} />;
|
||||
}
|
||||
|
||||
case 'empty': {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{item.data.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[styles],
|
||||
);
|
||||
|
||||
// FlashList的keyExtractor
|
||||
const keyExtractor = useCallback((item: ProviderFlashListItem) => item.id, []);
|
||||
|
||||
const renderSeparator = useCallback(() => <View style={styles.separator} />, [styles.separator]);
|
||||
|
||||
// Loading状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title={t('providers', { ns: 'setting' })}>
|
||||
<ProviderListSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Error状态
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title={t('providers', { ns: 'setting' })}>
|
||||
<View style={[styles.container, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<Text style={styles.label}>{t('aiProviders.list.loadFailed', { ns: 'setting' })}</Text>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title={t('providers', { ns: 'setting' })}>
|
||||
<View style={styles.container}>
|
||||
<FlashList
|
||||
ItemSeparatorComponent={renderSeparator}
|
||||
data={flashListData}
|
||||
drawDistance={400}
|
||||
getItemType={(item) => item.type}
|
||||
keyExtractor={keyExtractor}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={true}
|
||||
/>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderList;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
backButton: {
|
||||
marginRight: token.marginXS,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: token.padding,
|
||||
},
|
||||
// 空状态样式
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
padding: token.paddingXL,
|
||||
},
|
||||
|
||||
emptyText: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSize,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
eyeButton: {
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
right: token.marginXS,
|
||||
top: 0,
|
||||
width: 36,
|
||||
},
|
||||
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
|
||||
headerTitle: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: 17,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginRight: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
hint: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
marginTop: token.marginXS,
|
||||
},
|
||||
|
||||
input: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
borderWidth: 1,
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
height: 44,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
|
||||
inputContainer: {
|
||||
marginBottom: token.margin,
|
||||
position: 'relative',
|
||||
},
|
||||
|
||||
inputWithIcon: {
|
||||
paddingRight: 44,
|
||||
},
|
||||
|
||||
label: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
marginBottom: token.marginXS,
|
||||
},
|
||||
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Section样式 - 对标web端
|
||||
sectionHeader: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
paddingBottom: token.paddingXS,
|
||||
paddingHorizontal: token.padding,
|
||||
paddingTop: token.paddingLG,
|
||||
},
|
||||
|
||||
sectionSeparator: {
|
||||
height: 16, // 增加section间距
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: 18, // 对标web端字体大小
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
|
||||
// 卡片间分隔 - 对标web端Grid gap
|
||||
separator: {
|
||||
backgroundColor: 'transparent',
|
||||
height: 16, // 对标web端16px gap
|
||||
},
|
||||
|
||||
validateButton: {
|
||||
marginTop: token.margin,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,52 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useThemeMode as useAppTheme } from '@/theme';
|
||||
|
||||
import { SettingGroup, SettingItem } from '../(components)';
|
||||
import { useStyles } from '../styles';
|
||||
|
||||
export default function ThemeModeSettingScreen() {
|
||||
const { t } = useTranslation(['setting']);
|
||||
const { styles } = useStyles();
|
||||
const { themeMode, setThemeMode } = useAppTheme();
|
||||
|
||||
const isFollowSystem = themeMode === 'auto';
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.safeAreaView}
|
||||
title={t('themeMode.title', { ns: 'setting' })}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
onSwitchChange={(enabled) => setThemeMode(enabled ? 'auto' : 'light')}
|
||||
showSwitch
|
||||
switchValue={isFollowSystem}
|
||||
title={t('themeMode.auto', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
{!isFollowSystem && (
|
||||
<SettingGroup>
|
||||
<SettingItem
|
||||
isSelected={themeMode === 'light'}
|
||||
onPress={() => setThemeMode('light')}
|
||||
showCheckmark
|
||||
title={t('themeMode.light', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
isSelected={themeMode === 'dark'}
|
||||
onPress={() => setThemeMode('dark')}
|
||||
showCheckmark
|
||||
title={t('themeMode.dark', { ns: 'setting' })}
|
||||
/>
|
||||
</SettingGroup>
|
||||
)}
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: token.paddingLG,
|
||||
},
|
||||
link: {
|
||||
marginTop: token.marginLG,
|
||||
paddingVertical: token.paddingLG,
|
||||
},
|
||||
linkText: {
|
||||
color: token.colorPrimary,
|
||||
fontSize: token.fontSizeLG,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
title: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeHeading3,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['error', 'common']);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t('page.notFoundTitle', { ns: 'error' }) }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t('page.notFoundMessage', { ns: 'error' })}</Text>
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>{t('navigation.goToHomeScreen', { ns: 'common' })}</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
|
||||
import { PortalProvider } from '@gorhom/portal';
|
||||
import { ToastProvider } from '@lobehub/ui-rn';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as NavigationBar from 'expo-navigation-bar';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Platform } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||
import { RootSiblingParent } from 'react-native-root-siblings';
|
||||
|
||||
import i18n from '@/i18n';
|
||||
import { I18nReadyGate } from '@/i18n/ReadyGate';
|
||||
import { safeReplaceLogin } from '@/navigation/safeLogin';
|
||||
import { tokenRefreshManager } from '@/services/_auth/tokenRefresh';
|
||||
import { TRPCProvider, trpcClient } from '@/services/_auth/trpc';
|
||||
import { useAuth, useUserStore } from '@/store/user';
|
||||
import { ThemeProvider, useTheme, useThemeMode } from '@/theme';
|
||||
import { authLogger } from '@/utils/logger';
|
||||
|
||||
import '../polyfills';
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
function AuthProvider({ children }: PropsWithChildren) {
|
||||
const { isAuthenticated, isInitialized, error } = useAuth();
|
||||
const router = useRouter();
|
||||
const hasRedirectedRef = useRef(false);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
|
||||
// 初始化认证状态和令牌刷新管理器
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
// 避免重复初始化
|
||||
if (isInitialized || isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
authLogger.info('Initializing auth in RootLayout');
|
||||
setIsInitializing(true);
|
||||
|
||||
try {
|
||||
// 启动令牌刷新管理器
|
||||
tokenRefreshManager.start();
|
||||
|
||||
// 初始化用户认证状态
|
||||
await useUserStore.getState().initialize();
|
||||
authLogger.info('Auth initialization completed');
|
||||
} catch (error) {
|
||||
authLogger.error('Failed to initialize auth in RootLayout', error);
|
||||
console.error('Failed to initialize auth in RootLayout:', error);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
// Hide splash screen after auth initialization
|
||||
await SplashScreen.hideAsync();
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
tokenRefreshManager.stop();
|
||||
};
|
||||
}, [isInitialized, isInitializing]);
|
||||
|
||||
// 只在真正需要重新认证时才重定向
|
||||
useEffect(() => {
|
||||
if (!isInitialized || isInitializing || hasRedirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRedirectToLogin = async (): Promise<boolean> => {
|
||||
// 如果已认证,不需要重定向
|
||||
if (isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否是因为 refresh token 过期导致的未认证
|
||||
if (error?.includes('refresh_token') || error?.includes('Refresh token expired')) {
|
||||
authLogger.info('Refresh token expired, need to redirect to login');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果没有任何错误且未认证,检查是否有有效token
|
||||
if (!error) {
|
||||
const { TokenStorage } = await import('@/services/_auth/tokenStorage');
|
||||
const hasToken = await TokenStorage.hasValidToken();
|
||||
if (!hasToken) {
|
||||
authLogger.info('No valid token found, need to redirect to login');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleAuthCheck = async () => {
|
||||
try {
|
||||
const needsLogin = await shouldRedirectToLogin();
|
||||
if (needsLogin) {
|
||||
authLogger.info('Auth state requires login, redirecting');
|
||||
hasRedirectedRef.current = true;
|
||||
// 使用 setTimeout 确保在 Root Layout 挂载完成后进行导航
|
||||
setTimeout(() => {
|
||||
safeReplaceLogin(router);
|
||||
}, 0);
|
||||
}
|
||||
} catch (checkError) {
|
||||
authLogger.error('Error checking auth state:', checkError);
|
||||
}
|
||||
};
|
||||
|
||||
handleAuthCheck();
|
||||
|
||||
// 重置重定向标志当用户重新认证时
|
||||
if (isAuthenticated && hasRedirectedRef.current) {
|
||||
authLogger.info('User authenticated, resetting redirect flag');
|
||||
hasRedirectedRef.current = false;
|
||||
}
|
||||
}, [isInitialized, isInitializing, isAuthenticated, error, router]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
const QueryProvider = ({ children }: PropsWithChildren) => {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
|
||||
{children}
|
||||
</TRPCProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function ThemedSystemBars() {
|
||||
const { isDarkMode } = useThemeMode();
|
||||
const token = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
|
||||
NavigationBar.setBackgroundColorAsync(token.colorBgLayout).catch(() => {});
|
||||
NavigationBar.setButtonStyleAsync(isDarkMode ? 'light' : 'dark').catch(() => {});
|
||||
}, [isDarkMode, token.colorBgLayout]);
|
||||
|
||||
return (
|
||||
<StatusBar
|
||||
animated
|
||||
backgroundColor={token.colorBgLayout}
|
||||
style={isDarkMode ? 'light' : 'dark'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<ActionSheetProvider>
|
||||
<PortalProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<I18nReadyGate>
|
||||
<ToastProvider>
|
||||
<RootSiblingParent>
|
||||
<ThemedSystemBars />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
{/* 指定首页, 防止 expo 路由错乱 */}
|
||||
<Stack.Screen name="index" options={{ animation: 'none' }} />
|
||||
{/* main page should not have animation */}
|
||||
<Stack.Screen name="(main)/chat" options={{ animation: 'none' }} />
|
||||
{/* auth page should not have animation */}
|
||||
<Stack.Screen name="auth" options={{ animation: 'none' }} />
|
||||
</Stack>
|
||||
</RootSiblingParent>
|
||||
</ToastProvider>
|
||||
</I18nReadyGate>
|
||||
</I18nextProvider>
|
||||
</PortalProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
const AuthLayout = () => {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="callback"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import { ActivityIndicator, Text, View } from 'react-native';
|
||||
|
||||
// 处理 OAuth 回调的占位页面
|
||||
// Android 在完成外部浏览器登录后会通过 deep link 打开 com.lobehub.app://auth/callback
|
||||
// 如果没有该页面,会短暂进入 +not-found。这里立即跳转到首页,由首页再根据认证状态进入 /chat。
|
||||
export default function AuthCallback() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// 立即导航到首页,避免展示 404 页面
|
||||
const t = setTimeout(() => router.replace('/'), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', flex: 1, justifyContent: 'center' }}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={{ marginTop: 12 }}>正在处理登录...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Button } from '@lobehub/ui-rn';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Image, Text, View } from 'react-native';
|
||||
|
||||
import { setLoginMounted } from '@/navigation/loginState';
|
||||
import { useAuth, useAuthActions } from '@/store/user';
|
||||
import { getLoginErrorKey } from '@/utils/error';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const LoginPage = () => {
|
||||
const { t } = useTranslation(['auth', 'error', 'common']);
|
||||
const { isLoading } = useAuth();
|
||||
const { login } = useAuthActions();
|
||||
const router = useRouter();
|
||||
|
||||
const { styles } = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
setLoginMounted(true);
|
||||
return () => setLoginMounted(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await login();
|
||||
// 立即导航到对话页,避免展示 404 页面
|
||||
setTimeout(() => router.replace('/chat'), 0);
|
||||
} catch (error) {
|
||||
const key = getLoginErrorKey(error);
|
||||
const message = t(key, { ns: 'error' });
|
||||
Alert.alert(t('error.title', { ns: 'error' }), message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* Logo 和标题 */}
|
||||
<View style={styles.welcome}>
|
||||
<Image source={require('../../../assets/images/logo.png')} style={styles.logo} />
|
||||
|
||||
<Text style={styles.title}>LobeChat</Text>
|
||||
<Text style={styles.subtitle}>{t('login.subtitle', { ns: 'auth' })}</Text>
|
||||
</View>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<Button
|
||||
block
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
onPress={handleLogin}
|
||||
size="large"
|
||||
style={styles.loginButton}
|
||||
type="primary"
|
||||
>
|
||||
{t('login.button', { appName: 'LobeChat.com', ns: 'auth' })}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<View style={styles.securityNote}>
|
||||
<Text style={styles.securityText}>
|
||||
{t('login.securityNote', { ns: 'auth' })}
|
||||
<Link href="https://lobehub.com/terms" style={styles.securityLink}>
|
||||
《{t('login.usePolicy', { ns: 'auth' })}》
|
||||
</Link>
|
||||
{t('and', { ns: 'common' })}
|
||||
<Link href="https://lobehub.com/privacy" style={styles.securityLink}>
|
||||
《{t('login.privacyPolicy', { ns: 'auth' })}》
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { LOGO_SIZE } from '@/_const/common';
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
padding: token.paddingXL,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
maxWidth: token.screenSM,
|
||||
width: '100%',
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderRadius: token.borderRadius,
|
||||
marginBottom: token.marginLG,
|
||||
padding: token.padding,
|
||||
width: '100%',
|
||||
},
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
fontSize: token.fontSize,
|
||||
textAlign: 'center',
|
||||
},
|
||||
header: {},
|
||||
loginButton: {
|
||||
marginBottom: token.marginXL,
|
||||
},
|
||||
logo: {
|
||||
height: LOGO_SIZE,
|
||||
marginBottom: token.marginLG,
|
||||
width: LOGO_SIZE,
|
||||
},
|
||||
securityLink: {
|
||||
color: token.colorLink,
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeightSM,
|
||||
textAlign: 'center',
|
||||
},
|
||||
securityNote: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginLG,
|
||||
maxWidth: token.screenSM,
|
||||
width: '100%',
|
||||
},
|
||||
securityText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeightSM,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeight,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeHeading1,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginSM,
|
||||
},
|
||||
welcome: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginXXL,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { useThemedScreenOptions } from '@/_const/navigation';
|
||||
|
||||
export default function RoutesLayout() {
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...themedScreenOptions,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="assistant/[...slugs]" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Button, Icon, Markdown, PageContainer, Tag } from '@lobehub/ui-rn';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { BotMessageSquare } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, InteractionManager, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import DetailHeader from '@/features/discover/assistant/components/DetailHeader';
|
||||
import SkeletonDetail from '@/features/discover/assistant/components/SkeletonDetail';
|
||||
import { useDiscoverStore } from '@/store/discover';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const AssistantDetail = () => {
|
||||
const { slugs } = useLocalSearchParams<{ slugs: string[] }>();
|
||||
const identifier = decodeURIComponent(slugs.join('/'));
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['common', 'discover']);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const createSession = useSessionStore((s) => s.createSession);
|
||||
const toggleDrawer = useGlobalStore((s) => s.toggleDrawer);
|
||||
|
||||
const useAssistantDetail = useDiscoverStore((s) => s.useAssistantDetail);
|
||||
const {
|
||||
data: agent,
|
||||
error,
|
||||
isLoading,
|
||||
} = useAssistantDetail({
|
||||
identifier: identifier as string,
|
||||
});
|
||||
|
||||
// const handleAddAgentAndConverse = async () => {
|
||||
// if (!config) return;
|
||||
//
|
||||
// setIsLoading(true);
|
||||
// const session = await createSession({
|
||||
// config,
|
||||
// meta,
|
||||
// });
|
||||
// setIsLoading(false);
|
||||
// message.success(t('assistants.addAgentSuccess'));
|
||||
// router.push(SESSION_CHAT_URL(session, mobile));
|
||||
// };
|
||||
|
||||
const handleAddAssistant = async () => {
|
||||
if (!agent?.config) return;
|
||||
|
||||
const { config } = agent;
|
||||
const meta = {
|
||||
avatar: agent.avatar,
|
||||
backgroundColor: agent.backgroundColor,
|
||||
description: agent.description,
|
||||
tags: agent.tags,
|
||||
title: agent.title,
|
||||
};
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
// 添加到会话列表
|
||||
const session = await createSession(
|
||||
{
|
||||
config,
|
||||
meta,
|
||||
},
|
||||
false,
|
||||
);
|
||||
toggleDrawer();
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
// 导航到会话页面
|
||||
router.replace({
|
||||
params: { session: session },
|
||||
pathname: '/chat',
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(t('assistant.detail.addFailed', { ns: 'discover' }), err);
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
t('assistant.detail.addFailedMessage', { ns: 'discover' }),
|
||||
);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const handleShare = async () => {
|
||||
// if (!agent) return;
|
||||
//
|
||||
// try {
|
||||
// await Share.share({
|
||||
// message: `${agent.meta.title} - ${agent.meta.description} #LobeChat`,
|
||||
// url: agent.homepage || `https://chat.lobehub.com`,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('分享失败:', error);
|
||||
// }
|
||||
// };
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.safeAreaContainer}
|
||||
title={t('assistant.detail.title', { ns: 'discover' })}
|
||||
>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<SkeletonDetail />
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agent) {
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.errorContainer}
|
||||
title={t('assistant.detail.title', { ns: 'discover' })}
|
||||
>
|
||||
<Text style={styles.errorText}>
|
||||
{!identifier
|
||||
? t('assistant.detail.notFoundIdentifier', { ns: 'discover' })
|
||||
: t('assistant.detail.loadFailed', { ns: 'discover' })}
|
||||
</Text>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取系统角色内容(可能存在于不同的位置)
|
||||
const systemRoleContent = agent.config.systemRole;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
showBack
|
||||
style={styles.safeAreaContainer}
|
||||
title={t('assistant.detail.title', { ns: 'discover' })}
|
||||
>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.container}>
|
||||
{/* Header with avatar on left, title/author/date on right */}
|
||||
<DetailHeader
|
||||
author={agent.author || 'LobeChat'}
|
||||
avatar={agent.avatar || '🤖'}
|
||||
createdAt={agent.createdAt}
|
||||
title={agent.title}
|
||||
/>
|
||||
|
||||
{/* 描述信息 */}
|
||||
<Text style={styles.description}>{agent.description}</Text>
|
||||
|
||||
{/* 标签列表 */}
|
||||
{agent.tags && agent.tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{agent.tags.map((tag: string) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 添加助手与对话按钮 */}
|
||||
<View style={styles.actionButtonsContainer}>
|
||||
<Button
|
||||
block
|
||||
disabled={isAdding}
|
||||
loading={isAdding}
|
||||
onPress={handleAddAssistant}
|
||||
size="large"
|
||||
type="primary"
|
||||
>
|
||||
{t('assistant.detail.addAndChat', { ns: 'discover' })}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* 系统提示区域 */}
|
||||
{systemRoleContent && (
|
||||
<View style={styles.systemRoleContainer}>
|
||||
<View style={styles.settingsTitleContainer}>
|
||||
<Icon icon={BotMessageSquare} />
|
||||
<Text style={styles.systemRoleTitle}>
|
||||
{t('assistant.detail.assistantSettings', { ns: 'discover' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.systemRoleContentContainer}>
|
||||
<Markdown>{systemRoleContent}</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantDetail;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
// 操作按钮相关样式
|
||||
actionButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: token.marginLG,
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
addButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginRight: token.marginXS,
|
||||
paddingHorizontal: token.margin,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
addButtonText: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
|
||||
authorAvatar: {
|
||||
alignItems: 'center',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
height: token.controlHeightSM,
|
||||
justifyContent: 'center',
|
||||
marginRight: token.marginXS / 2,
|
||||
width: token.controlHeightSM,
|
||||
},
|
||||
|
||||
avatarTitleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
|
||||
// 头部相关样式
|
||||
backButton: {
|
||||
padding: token.marginXS,
|
||||
},
|
||||
|
||||
// 容器相关样式
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: token.padding,
|
||||
},
|
||||
|
||||
description: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeight,
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
},
|
||||
|
||||
iconText: {
|
||||
fontSize: token.fontSize,
|
||||
},
|
||||
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// 文字相关样式
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
marginTop: token.margin,
|
||||
},
|
||||
|
||||
safeAreaContainer: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
settingsIcon: {
|
||||
fontSize: token.fontSizeLG,
|
||||
},
|
||||
|
||||
settingsTitleContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderTopLeftRadius: token.borderRadiusLG,
|
||||
borderTopRightRadius: token.borderRadiusLG,
|
||||
flexDirection: 'row',
|
||||
padding: token.margin,
|
||||
},
|
||||
|
||||
shareButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
height: token.controlHeight,
|
||||
justifyContent: 'center',
|
||||
width: token.controlHeight,
|
||||
},
|
||||
|
||||
// 系统角色相关样式
|
||||
systemRoleContainer: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
marginBottom: token.marginLG,
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
systemRoleContentContainer: {
|
||||
// paddingVertical: 12,
|
||||
color: token.colorText,
|
||||
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeight,
|
||||
paddingHorizontal: token.margin,
|
||||
},
|
||||
|
||||
systemRoleTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginLeft: token.marginXS,
|
||||
},
|
||||
// 标签相关样式
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: token.marginXXS,
|
||||
marginBottom: token.marginLG,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,199 @@
|
||||
import { AssistantCategory, DiscoverAssistantItem } from '@lobechat/types';
|
||||
import { CapsuleTabs, Input, PageContainer } from '@lobehub/ui-rn';
|
||||
import { useDebounce } from 'ahooks';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, FlatList, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import AgentCard from '@/features/discover/assistant/components/AgentCard';
|
||||
import {
|
||||
AssistantListSkeleton,
|
||||
CategoryTabsSkeleton,
|
||||
} from '@/features/discover/assistant/components/SkeletonList';
|
||||
import useCategory from '@/features/discover/assistant/hooks/useCategory';
|
||||
import { useDiscoverStore } from '@/store/discover';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const INITIAL_PAGE_SIZE = 21;
|
||||
|
||||
const AssistantList = () => {
|
||||
const { styles, theme } = useStyles();
|
||||
const { t } = useTranslation(['common', 'discover']);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>(AssistantCategory.All);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [allItems, setAllItems] = useState<DiscoverAssistantItem[]>([]);
|
||||
const [hasMoreData, setHasMoreData] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const categories = useCategory();
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 });
|
||||
|
||||
const finalSearchQuery = useMemo(() => {
|
||||
if (searchQuery !== '') return searchQuery;
|
||||
if (searchText === '') return '';
|
||||
return debouncedSearchText;
|
||||
}, [searchQuery, searchText, debouncedSearchText]);
|
||||
|
||||
const useAssistantCategories = useDiscoverStore((s) => s.useAssistantCategories);
|
||||
const { data: categoryStats = [], isLoading: isCategoryLoading } = useAssistantCategories({
|
||||
q: finalSearchQuery,
|
||||
});
|
||||
|
||||
const categoriesWithStats = useMemo(() => {
|
||||
const total = categoryStats.reduce((acc, item) => acc + item.count, 0);
|
||||
|
||||
return categories.map((category) => {
|
||||
const itemData = categoryStats.find((stat) => stat.category === category.key);
|
||||
return {
|
||||
...category,
|
||||
count: category.key === AssistantCategory.All ? total : itemData?.count || 0,
|
||||
};
|
||||
});
|
||||
}, [categories, categoryStats]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
category: selectedCategory === AssistantCategory.All ? undefined : selectedCategory,
|
||||
page: currentPage,
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
q: finalSearchQuery || undefined,
|
||||
}),
|
||||
[finalSearchQuery, selectedCategory, currentPage],
|
||||
);
|
||||
|
||||
const useAssistantList = useDiscoverStore((s) => s.useAssistantList);
|
||||
const { data: agents, error, isLoading } = useAssistantList(queryParams);
|
||||
|
||||
const handleImmediateSearch = useCallback(
|
||||
(query?: string) => {
|
||||
const searchValue = query !== undefined ? query : searchText;
|
||||
setSearchQuery(searchValue);
|
||||
setTimeout(() => setSearchQuery(''), 100);
|
||||
},
|
||||
[searchText],
|
||||
);
|
||||
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
handleImmediateSearch();
|
||||
}, [handleImmediateSearch]);
|
||||
|
||||
const handleCategorySelect = useCallback((key: string) => {
|
||||
setSelectedCategory(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setAllItems([]);
|
||||
setHasMoreData(true);
|
||||
setIsLoadingMore(false);
|
||||
}, [finalSearchQuery, selectedCategory]);
|
||||
|
||||
// 处理新数据
|
||||
useEffect(() => {
|
||||
if (agents?.items) {
|
||||
if (currentPage === 1) {
|
||||
// 重置数据
|
||||
setAllItems(agents.items);
|
||||
} else {
|
||||
// 追加数据
|
||||
setAllItems((prev) => [...prev, ...agents.items]);
|
||||
}
|
||||
// 检查是否还有更多数据
|
||||
setHasMoreData(agents.items.length === INITIAL_PAGE_SIZE);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [agents, currentPage]);
|
||||
|
||||
// 处理加载更多
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!isLoadingMore && hasMoreData && !isLoading) {
|
||||
setIsLoadingMore(true);
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}
|
||||
}, [isLoadingMore, hasMoreData, isLoading]);
|
||||
|
||||
// 渲染底部加载指示器
|
||||
const renderFooter = useCallback(() => {
|
||||
if (!hasMoreData) return null;
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', padding: 20 }}>
|
||||
<ActivityIndicator color={theme.colorPrimary} size="small" />
|
||||
</View>
|
||||
);
|
||||
}, [hasMoreData, theme.colorPrimary]);
|
||||
|
||||
const renderEmptyComponent = useCallback(
|
||||
() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{finalSearchQuery
|
||||
? t('assistant.noMatch', { ns: 'common' })
|
||||
: t('assistant.noData', { ns: 'common' })}
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
[finalSearchQuery],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: DiscoverAssistantItem }) => <AgentCard item={item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: DiscoverAssistantItem) => item.identifier, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{t('assistant.fetchError', { ns: 'common' })}</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaContainer} title={t('title', { ns: 'discover' })}>
|
||||
<View style={styles.filterContainer}>
|
||||
<Input.Search
|
||||
onChangeText={setSearchText}
|
||||
onSubmitEditing={handleSearchSubmit}
|
||||
placeholder={t('assistant.search', { ns: 'common' })}
|
||||
size="large"
|
||||
style={styles.searchContainer}
|
||||
/>
|
||||
|
||||
{isCategoryLoading ? (
|
||||
<CategoryTabsSkeleton />
|
||||
) : (
|
||||
<CapsuleTabs
|
||||
items={categoriesWithStats}
|
||||
onSelect={handleCategorySelect}
|
||||
selectedKey={selectedCategory}
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isLoading && currentPage === 1 ? (
|
||||
<AssistantListSkeleton />
|
||||
) : (
|
||||
<FlatList
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
ListFooterComponent={renderFooter}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
data={allItems}
|
||||
keyExtractor={keyExtractor}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantList;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
padding: token.padding,
|
||||
},
|
||||
emptyText: {
|
||||
color: token.colorTextSecondary,
|
||||
},
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: token.paddingXS,
|
||||
paddingTop: token.padding,
|
||||
},
|
||||
listContainer: {
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
marginTop: token.margin,
|
||||
},
|
||||
safeAreaContainer: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
searchContainer: {
|
||||
marginBottom: token.paddingSM,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
searchIcon: {
|
||||
alignItems: 'center',
|
||||
borderRadius: token.borderRadius,
|
||||
height: token.controlHeight,
|
||||
justifyContent: 'center',
|
||||
width: token.controlHeight,
|
||||
},
|
||||
searchInput: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: token.fontSize,
|
||||
paddingVertical: token.marginXS,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: token.fontSizeXL,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
title: {
|
||||
fontSize: token.fontSizeHeading1,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { useAuth } from '@/store/user';
|
||||
|
||||
export default function Page() {
|
||||
const { isAuthenticated, isInitialized } = useAuth();
|
||||
|
||||
// 只有在认证完全初始化后才进行导航
|
||||
if (isInitialized && isAuthenticated) {
|
||||
return <Redirect href="/chat" />;
|
||||
}
|
||||
|
||||
// 在认证状态未确定时不渲染任何内容,让 SplashScreen 继续显示
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useThemedScreenOptions } from '@/_const/navigation';
|
||||
|
||||
export default function RoutesLayout() {
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...themedScreenOptions,
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import {
|
||||
BasicDemo,
|
||||
ColorsDemo,
|
||||
DisabledDemo,
|
||||
LoadingDemo,
|
||||
SizesDemo,
|
||||
VariantsDemo,
|
||||
} from '@lobehub/ui-rn/ActionIcon/demos';
|
||||
import README from '@lobehub/ui-rn/ActionIcon/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <ColorsDemo />, key: 'colors', title: '颜色' },
|
||||
{ component: <VariantsDemo />, key: 'variants', title: '不同视觉风格' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '尺寸' },
|
||||
{ component: <LoadingDemo />, key: 'loading', title: '加载状态' },
|
||||
{ component: <DisabledDemo />, key: 'disabled', title: '禁用状态' },
|
||||
];
|
||||
|
||||
export default function ActionIconPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="ActionIcon 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, BordersDemo, ErrorDemo, SizesDemo } from '@lobehub/ui-rn/Avatar/demos';
|
||||
import README from '@lobehub/ui-rn/Avatar/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
|
||||
{ component: <BordersDemo />, key: 'borders', title: '边框样式' },
|
||||
{ component: <ErrorDemo />, key: 'error', title: '错误处理' },
|
||||
];
|
||||
|
||||
export default function AvatarPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Avatar 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, ClickableDemo, LayoutDemo } from '@lobehub/ui-rn/Block/demos';
|
||||
import README from '@lobehub/ui-rn/Block/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <ClickableDemo />, key: 'clickable', title: '可点击状态' },
|
||||
{ component: <LayoutDemo />, key: 'layout', title: '布局示例' },
|
||||
];
|
||||
|
||||
export default function BlockPlaygroundPage() {
|
||||
return (
|
||||
<PageContainer showBack title="Block 块容器">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import {
|
||||
BasicDemo,
|
||||
BlockDemo,
|
||||
DangerDemo,
|
||||
DisabledDemo,
|
||||
IconDemo,
|
||||
LoadingDemo,
|
||||
ShapeDemo,
|
||||
SizesDemo,
|
||||
VariantColorDemo,
|
||||
} from '@lobehub/ui-rn/Button/demos';
|
||||
import README from '@lobehub/ui-rn/Button/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
|
||||
{ component: <DisabledDemo />, key: 'disabled', title: '禁用状态' },
|
||||
{ component: <BlockDemo />, key: 'block', title: '块级按钮' },
|
||||
{ component: <IconDemo />, key: 'icon', title: '图标按钮' },
|
||||
{ component: <ShapeDemo />, key: 'shape', title: '按钮形状' },
|
||||
{ component: <DangerDemo />, key: 'danger', title: '危险态按钮' },
|
||||
{ component: <VariantColorDemo />, key: 'variant-color', title: '变体与颜色' },
|
||||
{ component: <LoadingDemo />, key: 'loading', title: '加载状态' },
|
||||
];
|
||||
|
||||
export default function ButtonPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Button 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, IconsDemo, ScrollingDemo, SizesDemo } from '@lobehub/ui-rn/CapsuleTabs/demos';
|
||||
import README from '@lobehub/ui-rn/CapsuleTabs/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '尺寸大小' },
|
||||
{ component: <IconsDemo />, key: 'icons', title: '图标组合' },
|
||||
{ component: <ScrollingDemo />, key: 'scrolling', title: '水平滚动' },
|
||||
];
|
||||
|
||||
export default function CapsuleTabsPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="CapsuleTabs 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, WithCoverDemo } from '@lobehub/ui-rn/Card/demos';
|
||||
import README from '@lobehub/ui-rn/Card/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础卡片' },
|
||||
{ component: <WithCoverDemo />, key: 'with-cover', title: '带封面卡片' },
|
||||
];
|
||||
|
||||
export default function CardPlaygroundPage() {
|
||||
return (
|
||||
<PageContainer showBack title="Card 卡片">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo } from '@lobehub/ui-rn/Center/demos';
|
||||
import README from '@lobehub/ui-rn/Center/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
|
||||
|
||||
export default function CenterPlaygroundPage() {
|
||||
return (
|
||||
<PageContainer showBack title="Center 居中组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, FullDemo, TokenDemo } from '@lobehub/ui-rn/ColorScales/demos';
|
||||
import README from '@lobehub/ui-rn/ColorScales/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础演示' },
|
||||
{ component: <FullDemo />, key: 'full', title: '完整色板' },
|
||||
{ component: <TokenDemo />, key: 'token', title: 'Token 使用' },
|
||||
];
|
||||
|
||||
export default function ColorScalesPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="ColorScales 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { AdvancedDemo, BasicDemo } from '@lobehub/ui-rn/ColorSwatches/demos';
|
||||
import README from '@lobehub/ui-rn/ColorSwatches/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础演示' },
|
||||
{ component: <AdvancedDemo />, key: 'advanced', title: '高级演示' },
|
||||
];
|
||||
|
||||
export default function ColorSwatchesPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="ColorSwatches 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo } from '@lobehub/ui-rn/Flexbox/demos';
|
||||
import README from '@lobehub/ui-rn/Flexbox/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
|
||||
|
||||
export default function FlexboxPlaygroundPage() {
|
||||
return (
|
||||
<PageContainer showBack title="FlexBox 弹性布局">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, ComparisonDemo, SizesDemo, TypeDemo } from '@lobehub/ui-rn/FluentEmoji/demos';
|
||||
import README from '@lobehub/ui-rn/FluentEmoji/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
|
||||
{ component: <ComparisonDemo />, key: 'comparison', title: '3D vs 原始' },
|
||||
{ component: <TypeDemo />, key: 'type', title: '不同类型' },
|
||||
];
|
||||
|
||||
export default function FluentEmojiPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="FluentEmoji 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, RequiredMarkDemo, UseFormDemo, ValidatorDemo } from '@lobehub/ui-rn/Form/demos';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import README from '@/components/Form/README';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <UseFormDemo />, key: 'use-form', title: '使用 Form.useForm' },
|
||||
{ component: <RequiredMarkDemo />, key: 'required-mark', title: '必填标记' },
|
||||
{ component: <ValidatorDemo />, key: 'validator', title: '自定义校验' },
|
||||
];
|
||||
|
||||
export default function FormPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Form 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import {
|
||||
BasicHighlighterDemo,
|
||||
CompactHighlighterDemo,
|
||||
FullFeaturedHighlighterDemo,
|
||||
LanguagesHighlighterDemo,
|
||||
} from '@lobehub/ui-rn/Highlighter/demos';
|
||||
import README from '@lobehub/ui-rn/Highlighter/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicHighlighterDemo />, key: 'basic', title: '基础高亮' },
|
||||
{ component: <FullFeaturedHighlighterDemo />, key: 'fullFeatured', title: '完整功能' },
|
||||
{ component: <CompactHighlighterDemo />, key: 'compact', title: '紧凑型' },
|
||||
{ component: <LanguagesHighlighterDemo />, key: 'languages', title: '多语言' },
|
||||
];
|
||||
|
||||
export default function HighlighterPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Highlighter 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, ColorsDemo, SizesDemo, SpinDemo } from '@lobehub/ui-rn/Icon/demos';
|
||||
import README from '@lobehub/ui-rn/Icon/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <ColorsDemo />, key: 'colors', title: '颜色' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '尺寸' },
|
||||
{ component: <SpinDemo />, key: 'spin', title: '旋转动画' },
|
||||
];
|
||||
|
||||
export default function IconPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Icon 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import {
|
||||
BasicDemo,
|
||||
CompoundDemo,
|
||||
PasswordDemo,
|
||||
PrefixDemo,
|
||||
SearchDemo,
|
||||
SizesDemo,
|
||||
SuffixDemo,
|
||||
TextAreaDemo,
|
||||
VariantDemo,
|
||||
} from '@lobehub/ui-rn/Input/demos';
|
||||
import README from '@lobehub/ui-rn/Input/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <PrefixDemo />, key: 'prefix', title: '带前缀' },
|
||||
{ component: <SuffixDemo />, key: 'suffix', title: '带后缀' },
|
||||
{ component: <SearchDemo />, key: 'search', title: '搜索输入框' },
|
||||
{ component: <PasswordDemo />, key: 'password', title: '密码输入框' },
|
||||
{ component: <CompoundDemo />, key: 'compound', title: '复合组件' },
|
||||
{ component: <VariantDemo />, key: 'variant', title: '外观变体' },
|
||||
{ component: <TextAreaDemo />, key: 'textarea', title: '多行文本(autoSize)' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '尺寸大小' },
|
||||
];
|
||||
|
||||
export default function InputPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Input 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo, SizesDemo, StatesDemo } from '@lobehub/ui-rn/InstantSwitch/demos';
|
||||
import README from '@lobehub/ui-rn/InstantSwitch/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
|
||||
{ component: <StatesDemo />, key: 'states', title: '状态演示' },
|
||||
];
|
||||
|
||||
export default function InstantSwitchPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="InstantSwitch 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import {
|
||||
AdvancedDemo,
|
||||
AvatarsDemo,
|
||||
BasicDemo,
|
||||
NavigationDemo,
|
||||
} from '@lobehub/ui-rn/ListItem/demos';
|
||||
import README from '@lobehub/ui-rn/ListItem/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <AvatarsDemo />, key: 'avatars', title: '头像类型' },
|
||||
{ component: <NavigationDemo />, key: 'navigation', title: '导航交互' },
|
||||
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
|
||||
];
|
||||
|
||||
export default function ListItemPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="ListItem 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import { BasicDemo } from '@lobehub/ui-rn/Markdown/demos';
|
||||
import README from '@lobehub/ui-rn/Markdown/readme';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
|
||||
|
||||
export default function MarkdownPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Markdown 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import {
|
||||
AnimatedDemo,
|
||||
AvatarDemo,
|
||||
BasicDemo,
|
||||
ComplexDemo,
|
||||
CompoundDemo,
|
||||
ParagraphDemo,
|
||||
} from '@lobehub/ui-rn/Skeleton/demos';
|
||||
import README from '@lobehub/ui-rn/Skeleton/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <AnimatedDemo />, key: 'animated', title: '动画效果' },
|
||||
{ component: <AvatarDemo />, key: 'avatar', title: '头像骨架屏' },
|
||||
{ component: <ParagraphDemo />, key: 'paragraph', title: '段落骨架屏' },
|
||||
{ component: <CompoundDemo />, key: 'compound', title: '复合组件' },
|
||||
{ component: <ComplexDemo />, key: 'complex', title: '复杂示例' },
|
||||
];
|
||||
|
||||
export default function SkeletonPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Skeleton 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import { BasicDemo, ControlledDemo, MarksDemo, RangeDemo } from '@lobehub/ui-rn/Slider/demos';
|
||||
import README from '@lobehub/ui-rn/Slider/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <RangeDemo />, key: 'range', title: '不同范围' },
|
||||
{ component: <ControlledDemo />, key: 'controlled', title: '受控模式' },
|
||||
{ component: <MarksDemo />, key: 'marks', title: '刻度标记' },
|
||||
];
|
||||
|
||||
export default function SliderPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Slider 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import {
|
||||
AdvancedDemo,
|
||||
AlignmentDemo,
|
||||
BasicDemo,
|
||||
DirectionsDemo,
|
||||
SizesDemo,
|
||||
} from '@lobehub/ui-rn/Space/demos';
|
||||
import README from '@lobehub/ui-rn/Space/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <DirectionsDemo />, key: 'directions', title: '方向' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '间距大小' },
|
||||
{ component: <AlignmentDemo />, key: 'alignment', title: '对齐方式' },
|
||||
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
|
||||
];
|
||||
|
||||
export default function SpacePlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Space 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import { BasicDemo } from '@lobehub/ui-rn/Switch/demos';
|
||||
import README from '@lobehub/ui-rn/Switch/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
|
||||
|
||||
export default function SwitchPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Switch 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import {
|
||||
BasicDemo,
|
||||
BorderDemo,
|
||||
ColorsDemo,
|
||||
PresetDemo,
|
||||
UseCaseDemo,
|
||||
} from '@lobehub/ui-rn/Tag/demos';
|
||||
import README from '@lobehub/ui-rn/Tag/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <ColorsDemo />, key: 'colors', title: '颜色样式' },
|
||||
{ component: <PresetDemo />, key: 'preset', title: '预设颜色' },
|
||||
{ component: <BorderDemo />, key: 'border', title: '无边框' },
|
||||
{ component: <UseCaseDemo />, key: 'usecase', title: '实际应用' },
|
||||
];
|
||||
|
||||
export default function TagPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Tag 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
|
||||
import README from '@/theme/ThemeProvider/README';
|
||||
import {
|
||||
BasicDemo,
|
||||
CustomAlgorithmDemo,
|
||||
CustomTokenAndAlgorithmDemo,
|
||||
CustomTokenDemo,
|
||||
MultipleAlgorithmsDemo,
|
||||
} from '@/theme/ThemeProvider/demos';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const themeProviderDemos: DemoItem[] = [
|
||||
{
|
||||
component: <BasicDemo />,
|
||||
key: 'basic',
|
||||
title: '基础用法',
|
||||
},
|
||||
{
|
||||
component: <CustomTokenDemo />,
|
||||
key: 'customToken',
|
||||
title: '自定义 Token',
|
||||
},
|
||||
{
|
||||
component: <CustomAlgorithmDemo />,
|
||||
key: 'customAlgorithm',
|
||||
title: '自定义算法',
|
||||
},
|
||||
{
|
||||
component: <CustomTokenAndAlgorithmDemo />,
|
||||
key: 'customTokenAndAlgorithm',
|
||||
title: 'Token + 算法',
|
||||
},
|
||||
{
|
||||
component: <MultipleAlgorithmsDemo />,
|
||||
key: 'multipleAlgorithms',
|
||||
title: '多算法组合',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ThemeProviderPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="ThemeProvider 组件">
|
||||
<ComponentPlayground demos={themeProviderDemos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ThemeTokensPlayground from '@lobehub/ui-rn/theme/theme-token';
|
||||
|
||||
export default function ThemeTokensPlaygroundPage() {
|
||||
return <ThemeTokensPlayground />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import {
|
||||
AdvancedDemo,
|
||||
BasicDemo,
|
||||
IntegrationDemo,
|
||||
StaticDemo,
|
||||
TypesDemo,
|
||||
} from '@lobehub/ui-rn/Toast/demos';
|
||||
import README from '@lobehub/ui-rn/Toast/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <TypesDemo />, key: 'types', title: '类型演示' },
|
||||
{ component: <StaticDemo />, key: 'static', title: '静态方法' },
|
||||
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
|
||||
{ component: <IntegrationDemo />, key: 'integration', title: '集成示例' },
|
||||
];
|
||||
|
||||
export default function ToastPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Toast 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PageContainer } from '@lobehub/ui-rn';
|
||||
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
|
||||
import { AdvancedDemo, BasicDemo, PositionDemo, TriggerDemo } from '@lobehub/ui-rn/Tooltip/demos';
|
||||
import README from '@lobehub/ui-rn/Tooltip/readme';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
const demos: DemoItem[] = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <TriggerDemo />, key: 'trigger', title: '触发方式' },
|
||||
{ component: <PositionDemo />, key: 'position', title: '不同位置' },
|
||||
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
|
||||
];
|
||||
|
||||
export default function TooltipPlaygroundPage() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Tooltip 组件">
|
||||
<ComponentPlayground demos={demos} readmeContent={README} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { CapsuleTabItem, CapsuleTabs, Input, PageContainer, Tag } from '@lobehub/ui-rn';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ChevronRight } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
import { ComponentItem } from './type';
|
||||
import { COMPONENT_CONFIGS, getAllCategories, searchComponentsByName } from './utils';
|
||||
|
||||
export default function ComponentPlaygroundIndex() {
|
||||
const router = useRouter();
|
||||
const { styles, theme } = useStyles();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
|
||||
const categories = getAllCategories();
|
||||
|
||||
// 构建 CapsuleTabs 的数据
|
||||
const tabItems: CapsuleTabItem[] = [
|
||||
{ key: '', label: 'All' },
|
||||
...categories.map((category) => ({ key: category, label: category })),
|
||||
];
|
||||
|
||||
// 过滤组件
|
||||
const getFilteredComponents = (): ComponentItem[] => {
|
||||
let components = COMPONENT_CONFIGS;
|
||||
|
||||
if (searchText) {
|
||||
components = searchComponentsByName(searchText);
|
||||
}
|
||||
|
||||
if (selectedCategory) {
|
||||
components = components.filter((c) => c.category === selectedCategory);
|
||||
}
|
||||
|
||||
return components;
|
||||
};
|
||||
|
||||
const filteredComponents = getFilteredComponents();
|
||||
|
||||
const handleComponentPress = (component: ComponentItem) => {
|
||||
switch (component.path) {
|
||||
case 'tooltip': {
|
||||
router.push('/playground/components/tooltip');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'toast': {
|
||||
router.push('/playground/components/toast');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'highlighter': {
|
||||
router.push('/playground/components/highlighter');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'markdown': {
|
||||
router.push('/playground/components/markdown');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'listitem': {
|
||||
router.push('/playground/components/listitem');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'avatar': {
|
||||
router.push('/playground/components/avatar');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'space': {
|
||||
router.push('/playground/components/space');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'fluentemoji': {
|
||||
router.push('/playground/components/fluentemoji');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'tag': {
|
||||
router.push('/playground/components/tag');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'capsuletabs': {
|
||||
router.push('/playground/components/capsuletabs');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'button': {
|
||||
router.push('/playground/components/button');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'icon': {
|
||||
router.push('/playground/components/icon');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'action-icon': {
|
||||
router.push('/playground/components/action-icon');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'skeleton': {
|
||||
router.push('/playground/components/skeleton');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'instant-switch': {
|
||||
router.push('/playground/components/instant-switch');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'colorswatches': {
|
||||
router.push('/playground/components/colorswatches');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'colorscales': {
|
||||
router.push('/playground/components/colorscales');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'slider': {
|
||||
router.push('/playground/components/slider');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'theme-provider': {
|
||||
router.push('/playground/components/theme-provider');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'theme-theme': {
|
||||
router.push('/playground/components/theme-token');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'switch': {
|
||||
router.push('/playground/components/switch');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'input': {
|
||||
router.push('/playground/components/input');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'form': {
|
||||
router.push('/playground/components/form');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'flexbox': {
|
||||
router.push('/playground/components/flexbox');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'center': {
|
||||
router.push('/playground/components/center');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
router.push('/playground/components/block');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'card': {
|
||||
router.push('/playground/components/card');
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
alert(`${component.name} 组件页面正在建设中`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderComponentCard = (component: ComponentItem) => (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
key={component.name}
|
||||
onPress={() => handleComponentPress(component)}
|
||||
style={styles.componentCard}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.componentName}>{component.name}</Text>
|
||||
<View style={styles.badges}>
|
||||
{component.hasReadme && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>README</Text>
|
||||
</View>
|
||||
)}
|
||||
{component.hasDemos && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>DEMO</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.componentDescription}>{component.description}</Text>
|
||||
<View style={styles.tagsContainer}>
|
||||
{component.tags.slice(0, 3).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={styles.categoryText}>{component.category}</Text>
|
||||
<ChevronRight color={theme.colorTextTertiary} size={20} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer showBack style={styles.safeAreaView} title="Playground">
|
||||
<View style={styles.filterContainer}>
|
||||
<Input.Search
|
||||
onChangeText={setSearchText}
|
||||
placeholder="搜索组件..."
|
||||
size="large"
|
||||
style={styles.searchContainer}
|
||||
value={searchText}
|
||||
/>
|
||||
|
||||
<View style={styles.filterTabs}>
|
||||
<CapsuleTabs
|
||||
items={tabItems}
|
||||
onSelect={setSelectedCategory}
|
||||
selectedKey={selectedCategory}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
size="large"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} style={styles.container}>
|
||||
<View style={styles.componentList}>{filteredComponents.map(renderComponentCard)}</View>
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => ({
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
borderRadius: token.borderRadius,
|
||||
height: token.controlHeight,
|
||||
justifyContent: 'center',
|
||||
marginRight: token.marginSM,
|
||||
width: token.controlHeight,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: token.colorBgContainerSecondary,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
paddingHorizontal: token.paddingXS,
|
||||
paddingVertical: token.paddingXXS,
|
||||
},
|
||||
badgeText: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeIcon,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
badges: {
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
},
|
||||
cardFooter: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cardHeader: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: token.marginXS,
|
||||
},
|
||||
categoryText: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSizeSM,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
componentCard: {
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
borderWidth: token.lineWidth,
|
||||
elevation: 2,
|
||||
marginBottom: token.marginSM,
|
||||
padding: token.padding,
|
||||
},
|
||||
componentDescription: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeight,
|
||||
marginBottom: token.marginSM,
|
||||
},
|
||||
componentList: {
|
||||
gap: token.marginSM,
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
componentName: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: token.fontSizeHeading4,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
filterTab: {
|
||||
borderRadius: token.borderRadius,
|
||||
marginRight: token.marginXS,
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
filterTabText: {
|
||||
fontSize: token.fontSize,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
filterTabs: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: token.lineWidth,
|
||||
flexDirection: 'row',
|
||||
padding: token.paddingLG,
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
},
|
||||
safeAreaView: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
searchContainer: {
|
||||
marginBottom: token.marginSM,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: token.marginXS,
|
||||
},
|
||||
searchInput: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: token.fontSize,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: token.fontSize,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: token.marginXXS,
|
||||
marginBottom: token.marginXS,
|
||||
},
|
||||
title: {
|
||||
fontSize: token.fontSizeHeading2,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
lineHeight: token.lineHeightHeading2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 组件相关的类型定义
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export interface ComponentItem {
|
||||
category: 'basic' | 'layout' | 'feedback' | 'display' | 'animation' | 'form' | 'navigation';
|
||||
description: string;
|
||||
hasDemos: boolean;
|
||||
hasReadme: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ComponentStats {
|
||||
beta: number;
|
||||
categories: number;
|
||||
demo: number;
|
||||
stable: number;
|
||||
tags: number;
|
||||
total: number;
|
||||
withDemos: number;
|
||||
withReadme: number;
|
||||
}
|
||||
|
||||
export interface DemoConfig {
|
||||
code?: string;
|
||||
component: React.ComponentType<any>;
|
||||
description: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
component: ComponentItem;
|
||||
demos?: DemoConfig[];
|
||||
readme?: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user